Skip to main content

hjkl_engine/
editor.rs

1//! Editor — the public sqeel-vim type, layered over `hjkl_buffer::View`.
2//!
3//! This file owns the public Editor API — construction, content access,
4//! mouse and goto helpers, the (buffer-level) undo stack, and insert-mode
5//! session bookkeeping. All vim-specific keyboard handling lives in
6//! [`vim`] and communicates with Editor through a small internal API
7//! exposed via `pub(super)` fields and helper methods.
8
9use std::sync::atomic::{AtomicU16, Ordering};
10use std::time::SystemTime;
11
12/// Map a [`hjkl_buffer::Edit`] to one or more SPEC
13/// [`crate::types::Edit`] (`EditOp`) records.
14///
15/// Most buffer edits map to a single EditOp. Block ops
16/// ([`hjkl_buffer::Edit::InsertBlock`] /
17/// [`hjkl_buffer::Edit::DeleteBlockChunks`]) emit one EditOp per row
18/// touched — they edit non-contiguous cells and a single
19/// `range..range` can't represent the rectangle.
20///
21/// Returns an empty vec when the edit isn't representable (no buffer
22/// variant currently fails this check).
23fn edit_to_editops(edit: &hjkl_buffer::Edit) -> Vec<crate::types::Edit> {
24    use crate::types::{Edit as Op, Pos};
25    use hjkl_buffer::Edit as B;
26    let to_pos = |p: hjkl_buffer::Position| Pos {
27        line: p.row as u32,
28        col: p.col as u32,
29    };
30    match edit {
31        B::InsertChar { at, ch } => vec![Op {
32            range: to_pos(*at)..to_pos(*at),
33            replacement: ch.to_string(),
34        }],
35        B::InsertStr { at, text } => vec![Op {
36            range: to_pos(*at)..to_pos(*at),
37            replacement: text.clone(),
38        }],
39        B::DeleteRange { start, end, .. } => vec![Op {
40            range: to_pos(*start)..to_pos(*end),
41            replacement: String::new(),
42        }],
43        B::Replace { start, end, with } => vec![Op {
44            range: to_pos(*start)..to_pos(*end),
45            replacement: with.clone(),
46        }],
47        B::JoinLines {
48            row,
49            count,
50            with_space,
51        } => {
52            // Joining `count` rows after `row` collapses
53            // [(row+1, 0) .. (row+count, EOL)] into the joined
54            // sentinel. The replacement is either an empty string
55            // (gJ) or " " between segments (J).
56            let start = Pos {
57                line: *row as u32 + 1,
58                col: 0,
59            };
60            let end = Pos {
61                line: (*row + *count) as u32,
62                col: u32::MAX, // covers to EOL of the last source row
63            };
64            vec![Op {
65                range: start..end,
66                replacement: if *with_space {
67                    " ".into()
68                } else {
69                    String::new()
70                },
71            }]
72        }
73        B::SplitLines {
74            row,
75            cols,
76            inserted_spaces: _,
77        } => {
78            // SplitLines reverses a JoinLines: insert a `\n`
79            // (and optional dropped space) at each col on `row`.
80            cols.iter()
81                .map(|c| {
82                    let p = Pos {
83                        line: *row as u32,
84                        col: *c as u32,
85                    };
86                    Op {
87                        range: p..p,
88                        replacement: "\n".into(),
89                    }
90                })
91                .collect()
92        }
93        B::InsertBlock { at, chunks } => {
94            // One EditOp per row in the block — non-contiguous edits.
95            chunks
96                .iter()
97                .enumerate()
98                .map(|(i, chunk)| {
99                    let p = Pos {
100                        line: at.row as u32 + i as u32,
101                        col: at.col as u32,
102                    };
103                    Op {
104                        range: p..p,
105                        replacement: chunk.clone(),
106                    }
107                })
108                .collect()
109        }
110        B::DeleteBlockChunks {
111            at,
112            widths,
113            pads: _,
114        } => {
115            // One EditOp per row, deleting `widths[i]` chars at
116            // `(at.row + i, at.col)`. Best-effort: doesn't account for
117            // `pads` (see `Edit::DeleteBlockChunks` doc) — this mapping is
118            // already documented as a placeholder, and `pads` is only ever
119            // non-zero on a path nothing currently applies (audit-r2 fix 6).
120            widths
121                .iter()
122                .enumerate()
123                .map(|(i, w)| {
124                    let start = Pos {
125                        line: at.row as u32 + i as u32,
126                        col: at.col as u32,
127                    };
128                    let end = Pos {
129                        line: at.row as u32 + i as u32,
130                        col: at.col as u32 + *w as u32,
131                    };
132                    Op {
133                        range: start..end,
134                        replacement: String::new(),
135                    }
136                })
137                .collect()
138        }
139    }
140}
141
142/// Sum of bytes from the start of the buffer to the start of `row`.
143/// Byte offset of the first byte of `row` within the canonical
144/// `lines().join("\n")` byte rendering. Pre-rope this walked every row
145/// from 0 to `row` allocating a `String` per row to read its `.len()` —
146/// O(row) allocations per call, fired from `position_to_byte_coords` on
147/// every `insert_char`. At the bottom of a 1.86 M-line buffer that was
148/// 1.86 M String allocations per keystroke (the dominant cost of the
149/// "edits at the bottom of the file are slow" symptom).
150///
151/// Now O(log N): ropey's `line_to_byte` walks the B-tree's per-node
152/// byte counts. No String materialization.
153#[inline]
154fn buffer_byte_of_row(buf: &hjkl_buffer::View, row: usize) -> usize {
155    let rope = buf.rope();
156    let row = row.min(rope.len_lines());
157    rope.line_to_byte(row)
158}
159
160/// Convert an `hjkl_buffer::Position` (char-indexed col) into byte
161/// coordinates `(byte_within_buffer, (row, col_byte))` against the
162/// **pre-edit** buffer.
163fn position_to_byte_coords(
164    buf: &hjkl_buffer::View,
165    pos: hjkl_buffer::Position,
166) -> (usize, (u32, u32)) {
167    let row = pos.row.min(buf.row_count().saturating_sub(1));
168    let rope = buf.rope();
169    let line = hjkl_buffer::rope_line_str(&rope, row);
170    let col_byte = pos.byte_offset(&line);
171    let byte = buffer_byte_of_row(buf, row) + col_byte;
172    (byte, (row as u32, col_byte as u32))
173}
174
175/// Walk `bytes[..end]` counting newlines and return the (row, col_byte)
176/// position at byte offset `end`. `col_byte` is the byte distance from
177/// the most recent `\n` (or buffer start). Used to translate a byte
178/// offset into a tree-sitter `Point`.
179fn byte_to_row_col(bytes: &[u8], end: usize) -> (u32, u32) {
180    let end = end.min(bytes.len());
181    let mut row: u32 = 0;
182    let mut row_start: usize = 0;
183    for (i, &b) in bytes[..end].iter().enumerate() {
184        if b == b'\n' {
185            row += 1;
186            row_start = i + 1;
187        }
188    }
189    (row, (end - row_start) as u32)
190}
191
192/// Rope-backed minimal content-edit diff for the undo/redo
193/// `restore_text` path. Walks `old_rope` chunk-by-chunk for the
194/// common-prefix / common-suffix scan instead of forcing a full
195/// `content_joined()` materialization (~3 MB per undo on huge files).
196///
197/// `ropey::Rope::bytes()` and `bytes_at(n).reversed()` give O(log N)
198/// seek + O(1)-per-byte step, so the scan cost matches the contiguous
199/// `&[u8]` version without the materialization alloc.
200fn minimal_content_edit_rope(old_rope: &ropey::Rope, new_text: &str) -> crate::types::ContentEdit {
201    let new_bytes = new_text.as_bytes();
202    let old_len = old_rope.len_bytes();
203    let new_len = new_bytes.len();
204    let common = old_len.min(new_len);
205
206    // Common prefix length — forward walk through rope bytes.
207    let mut prefix = 0;
208    let mut fwd = old_rope.bytes();
209    while prefix < common {
210        match fwd.next() {
211            Some(b) if b == new_bytes[prefix] => prefix += 1,
212            _ => break,
213        }
214    }
215    while prefix > 0 && prefix < old_len && (old_rope.byte(prefix) & 0b1100_0000) == 0b1000_0000 {
216        prefix -= 1;
217    }
218
219    // Common suffix length — backward walk through rope bytes.
220    let mut suffix = 0;
221    let max_suffix = (old_len - prefix).min(new_len - prefix);
222    let mut rev = old_rope.bytes_at(old_len).reversed();
223    while suffix < max_suffix {
224        match rev.next() {
225            Some(b) if b == new_bytes[new_len - 1 - suffix] => suffix += 1,
226            _ => break,
227        }
228    }
229    while suffix > 0
230        && suffix < old_len
231        && (old_rope.byte(old_len - suffix) & 0b1100_0000) == 0b1000_0000
232    {
233        suffix -= 1;
234    }
235
236    let start_byte = prefix;
237    let old_end_byte = old_len - suffix;
238    let new_end_byte = new_len - suffix;
239
240    crate::types::ContentEdit {
241        start_byte,
242        old_end_byte,
243        new_end_byte,
244        start_position: rope_byte_to_row_col(old_rope, start_byte),
245        old_end_position: rope_byte_to_row_col(old_rope, old_end_byte),
246        new_end_position: byte_to_row_col(new_bytes, new_end_byte),
247    }
248}
249
250#[inline]
251fn rope_byte_to_row_col(rope: &ropey::Rope, byte_idx: usize) -> (u32, u32) {
252    let byte_idx = byte_idx.min(rope.len_bytes());
253    let line = rope.byte_to_line(byte_idx);
254    let line_start = rope.line_to_byte(line);
255    (line as u32, (byte_idx - line_start) as u32)
256}
257
258/// Compute the byte position after inserting `text` starting at
259/// `start_byte` / `start_pos`. Returns `(end_byte, end_position)`.
260fn advance_by_text(text: &str, start_byte: usize, start_pos: (u32, u32)) -> (usize, (u32, u32)) {
261    let new_end_byte = start_byte + text.len();
262    let newlines = text.bytes().filter(|&b| b == b'\n').count();
263    let end_pos = if newlines == 0 {
264        (start_pos.0, start_pos.1 + text.len() as u32)
265    } else {
266        // Bytes after the last newline determine the trailing column.
267        let last_nl = text.rfind('\n').unwrap();
268        let tail_bytes = (text.len() - last_nl - 1) as u32;
269        (start_pos.0 + newlines as u32, tail_bytes)
270    };
271    (new_end_byte, end_pos)
272}
273
274/// Translate a single `hjkl_buffer::Edit` into one or more
275/// [`crate::types::ContentEdit`] records using the **pre-edit** buffer
276/// state for byte/position lookups. Block ops fan out to one entry per
277/// touched row (matches `edit_to_editops`).
278fn content_edits_from_buffer_edit(
279    buf: &hjkl_buffer::View,
280    edit: &hjkl_buffer::Edit,
281) -> Vec<crate::types::ContentEdit> {
282    use hjkl_buffer::Edit as B;
283    use hjkl_buffer::Position;
284
285    let mut out: Vec<crate::types::ContentEdit> = Vec::new();
286
287    match edit {
288        B::InsertChar { at, ch } => {
289            let (start_byte, start_pos) = position_to_byte_coords(buf, *at);
290            let new_end_byte = start_byte + ch.len_utf8();
291            let new_end_pos = (start_pos.0, start_pos.1 + ch.len_utf8() as u32);
292            out.push(crate::types::ContentEdit {
293                start_byte,
294                old_end_byte: start_byte,
295                new_end_byte,
296                start_position: start_pos,
297                old_end_position: start_pos,
298                new_end_position: new_end_pos,
299            });
300        }
301        B::InsertStr { at, text } => {
302            let (start_byte, start_pos) = position_to_byte_coords(buf, *at);
303            let (new_end_byte, new_end_pos) = advance_by_text(text, start_byte, start_pos);
304            out.push(crate::types::ContentEdit {
305                start_byte,
306                old_end_byte: start_byte,
307                new_end_byte,
308                start_position: start_pos,
309                old_end_position: start_pos,
310                new_end_position: new_end_pos,
311            });
312        }
313        B::DeleteRange { start, end, kind } => {
314            let (start, end) = if start <= end {
315                (*start, *end)
316            } else {
317                (*end, *start)
318            };
319            match kind {
320                hjkl_buffer::MotionKind::Char => {
321                    let (start_byte, start_pos) = position_to_byte_coords(buf, start);
322                    let (old_end_byte, old_end_pos) = position_to_byte_coords(buf, end);
323                    out.push(crate::types::ContentEdit {
324                        start_byte,
325                        old_end_byte,
326                        new_end_byte: start_byte,
327                        start_position: start_pos,
328                        old_end_position: old_end_pos,
329                        new_end_position: start_pos,
330                    });
331                }
332                hjkl_buffer::MotionKind::Line => {
333                    // Linewise delete drops rows [lo..=hi] (both clamped,
334                    // matching `do_delete_range`). When `hi` is not the
335                    // last row the removed bytes are [byte_of_row(lo),
336                    // byte_of_row(hi + 1)). When `hi` IS the last row the
337                    // buffer removes through the true end of the document
338                    // and — when rows survive above — ALSO the '\n' that
339                    // ends row `lo - 1` (so no trailing-newline orphan is
340                    // left), so the edit must start at EOL of row lo-1.
341                    let n = buf.row_count();
342                    let lo = start.row.min(n.saturating_sub(1));
343                    let hi = end.row.min(n.saturating_sub(1));
344                    let rope = buf.rope();
345                    let (start_byte, start_position) = if hi + 1 < n {
346                        (buffer_byte_of_row(buf, lo), (lo as u32, 0))
347                    } else if lo > 0 {
348                        let prev_len = hjkl_buffer::rope_line_bytes(&rope, lo - 1);
349                        (
350                            buffer_byte_of_row(buf, lo) - 1,
351                            ((lo - 1) as u32, prev_len as u32),
352                        )
353                    } else {
354                        (0, (0, 0))
355                    };
356                    let (old_end_byte, old_end_position) = if hi + 1 < n {
357                        (buffer_byte_of_row(buf, hi + 1), ((hi + 1) as u32, 0))
358                    } else {
359                        let len = rope.len_bytes();
360                        (len, rope_byte_to_row_col(&rope, len))
361                    };
362                    out.push(crate::types::ContentEdit {
363                        start_byte,
364                        old_end_byte,
365                        new_end_byte: start_byte,
366                        start_position,
367                        old_end_position,
368                        new_end_position: start_position,
369                    });
370                }
371                hjkl_buffer::MotionKind::Block => {
372                    // Block delete removes a rectangle of chars per row.
373                    // Fan out to one ContentEdit per row, in DESCENDING
374                    // row order: consumers (tree-sitter `tree.edit`, LSP
375                    // didChange, sibling rebase) apply the batch
376                    // sequentially, each edit against the document as
377                    // already modified by the previous ones. Bottom-up,
378                    // every edit's pre-edit byte offsets stay valid
379                    // because prior edits only touched bytes strictly
380                    // after it. (Ascending emission left each later
381                    // row's byte offsets too high by the widths already
382                    // deleted above it.) Rows past the last row are
383                    // skipped, matching `do_delete_range`'s clamp —
384                    // iterating them would emit duplicate edits for the
385                    // clamped last row.
386                    let (left_col, right_col) = (start.col.min(end.col), start.col.max(end.col));
387                    let hi_row = end.row.min(buf.row_count().saturating_sub(1));
388                    for row in (start.row..=hi_row).rev() {
389                        let row_start_pos = Position::new(row, left_col);
390                        let row_end_pos = Position::new(row, right_col + 1);
391                        let (sb, sp) = position_to_byte_coords(buf, row_start_pos);
392                        let (eb, ep) = position_to_byte_coords(buf, row_end_pos);
393                        if eb <= sb {
394                            continue;
395                        }
396                        out.push(crate::types::ContentEdit {
397                            start_byte: sb,
398                            old_end_byte: eb,
399                            new_end_byte: sb,
400                            start_position: sp,
401                            old_end_position: ep,
402                            new_end_position: sp,
403                        });
404                    }
405                }
406            }
407        }
408        B::Replace { start, end, with } => {
409            let (start, end) = if start <= end {
410                (*start, *end)
411            } else {
412                (*end, *start)
413            };
414            let (start_byte, start_pos) = position_to_byte_coords(buf, start);
415            let (old_end_byte, old_end_pos) = position_to_byte_coords(buf, end);
416            let (new_end_byte, new_end_pos) = advance_by_text(with, start_byte, start_pos);
417            out.push(crate::types::ContentEdit {
418                start_byte,
419                old_end_byte,
420                new_end_byte,
421                start_position: start_pos,
422                old_end_position: old_end_pos,
423                new_end_position: new_end_pos,
424            });
425        }
426        B::JoinLines {
427            row,
428            count,
429            with_space,
430        } => {
431            // Mirrors `do_join_lines` exactly: each join removes the
432            // single '\n' byte that ends `row` and, when `with_space`
433            // and BOTH the accumulated line and the incoming line are
434            // non-empty, inserts one space in its place. The joined
435            // line's content is KEPT in the buffer, so the per-join
436            // byte change is exactly `"\n" → ""` or `"\n" → " "` —
437            // never the whole span down to EOL of the last joined row.
438            // One ContentEdit per join, each expressed against the
439            // document as already modified by the previous joins (the
440            // sequential-consumer contract shared by tree-sitter
441            // `tree.edit`, LSP didChange and sibling rebase).
442            let n = buf.row_count();
443            let row = (*row).min(n.saturating_sub(1));
444            let buf_rope = buf.rope();
445            let row_start_byte = buffer_byte_of_row(buf, row);
446            // Evolving byte length of the merged line, and how many
447            // rows the document still has before each join.
448            let mut line_bytes = hjkl_buffer::rope_line_bytes(&buf_rope, row);
449            let mut rows_left = n;
450            for k in 0..(*count).max(1) {
451                if row + 1 >= rows_left {
452                    break; // same stop condition as `do_join_lines`
453                }
454                // Pre-edit index of the line being pulled up.
455                let next_bytes = hjkl_buffer::rope_line_bytes(&buf_rope, row + 1 + k);
456                let start_byte = row_start_byte + line_bytes;
457                let start_pos = (row as u32, line_bytes as u32);
458                let insert_space = *with_space && line_bytes > 0 && next_bytes > 0;
459                let (new_end_byte, new_end_pos) = if insert_space {
460                    (start_byte + 1, (row as u32, line_bytes as u32 + 1))
461                } else {
462                    (start_byte, start_pos)
463                };
464                out.push(crate::types::ContentEdit {
465                    start_byte,
466                    old_end_byte: start_byte + 1, // the '\n'
467                    new_end_byte,
468                    start_position: start_pos,
469                    old_end_position: ((row + 1) as u32, 0),
470                    new_end_position: new_end_pos,
471                });
472                line_bytes += next_bytes + usize::from(insert_space);
473                rows_left -= 1;
474            }
475        }
476        B::SplitLines {
477            row,
478            cols,
479            inserted_spaces,
480        } => {
481            // `do_split_lines` applies `cols` in REVERSE (right-to-left) —
482            // not left-to-right — so later-processed (rightward) splits
483            // never shift the byte offsets of an earlier-processed
484            // (leftward) one. Mirror that order: cols.iter().rev().
485            //
486            // `inserted_spaces[idx]` (per-col — audit-r2 fix 6; NOT a
487            // uniform flag, since a multi-join batch can mix joins that
488            // did and didn't insert a space) tells us whether THIS split
489            // REPLACES a space byte with '\n' (remove the space, then
490            // insert '\n' at the same index) rather than a bare "\n"
491            // insert — but ONLY when that col is still within the row's
492            // *current* (shrinking, as each split truncates the row) char
493            // count AND the char actually there is a space, mirroring
494            // `do_split_lines`'s own defensive double-check. Track a
495            // shrinking `current_lc` (the row's live char count, exactly
496            // as `do_split_lines` recomputes via `rope_line_char_count`)
497            // so this arm reproduces that exactly, byte-for-byte.
498            let row = (*row).min(buf.row_count().saturating_sub(1));
499            let split_rope = buf.rope();
500            let line = hjkl_buffer::rope_line_str(&split_rope, row);
501            let mut current_lc = line.chars().count();
502            for (idx, &col) in cols.iter().enumerate().rev() {
503                let col_inserted_space = inserted_spaces.get(idx).copied().unwrap_or(false);
504                let has_space =
505                    col_inserted_space && col < current_lc && line.chars().nth(col) == Some(' ');
506                // `do_split_lines` never clamps `split_col` when this col's
507                // flag is set (even when out of range — see the
508                // has_space=false-past-EOL case above); only the no-space
509                // branch clamps to the live row length.
510                let split_col = if col_inserted_space {
511                    col
512                } else {
513                    col.min(current_lc)
514                };
515                let start_pos = Position::new(row, split_col);
516                let (start_byte, start_p) = position_to_byte_coords(buf, start_pos);
517                if has_space {
518                    // Space (1 byte) replaced by '\n' (1 byte).
519                    let end_pos = Position::new(row, split_col + 1);
520                    let (old_end_byte, old_end_p) = position_to_byte_coords(buf, end_pos);
521                    out.push(crate::types::ContentEdit {
522                        start_byte,
523                        old_end_byte,
524                        new_end_byte: start_byte + 1,
525                        start_position: start_p,
526                        old_end_position: old_end_p,
527                        new_end_position: (start_p.0 + 1, 0),
528                    });
529                } else {
530                    let (new_end_byte, new_end_pos) = advance_by_text("\n", start_byte, start_p);
531                    out.push(crate::types::ContentEdit {
532                        start_byte,
533                        old_end_byte: start_byte,
534                        new_end_byte,
535                        start_position: start_p,
536                        old_end_position: start_p,
537                        new_end_position: new_end_pos,
538                    });
539                }
540                current_lc = split_col;
541            }
542        }
543        B::InsertBlock { at, chunks } => {
544            // One ContentEdit per chunk, each landing at `(at.row + i,
545            // at.col)` in the pre-edit buffer. Rows share one contiguous
546            // rope, so inserting into an upper row shifts the byte
547            // offsets of every row below it — emit DESCENDING (bottom
548            // row first), same fix as block-delete (commit a57161d8):
549            // a lower row's edit, applied first by a sequential
550            // consumer, never touches bytes above it, so every row's
551            // pre-edit offset (computed once here, against `buf`) stays
552            // valid through the whole batch.
553            for (i, chunk) in chunks.iter().enumerate().rev() {
554                let pos = Position::new(at.row + i, at.col);
555                let (start_byte, start_pos) = position_to_byte_coords(buf, pos);
556                let (new_end_byte, new_end_pos) = advance_by_text(chunk, start_byte, start_pos);
557                out.push(crate::types::ContentEdit {
558                    start_byte,
559                    old_end_byte: start_byte,
560                    new_end_byte,
561                    start_position: start_pos,
562                    old_end_position: start_pos,
563                    new_end_position: new_end_pos,
564                });
565            }
566        }
567        B::DeleteBlockChunks { at, widths, pads } => {
568            // Same descending-order requirement as InsertBlock above.
569            for (i, w) in widths.iter().enumerate().rev() {
570                let row = at.row + i;
571                // `pads[i]` extends the removed span to the left of
572                // at.col (see the `Edit::DeleteBlockChunks` field doc) —
573                // include it so this stays byte-exact for the padded case
574                // too, not just the chunk-only span.
575                let pad = pads.get(i).copied().unwrap_or(0);
576                let start_pos = Position::new(row, at.col.saturating_sub(pad));
577                let end_pos = Position::new(row, at.col + *w);
578                let (sb, sp) = position_to_byte_coords(buf, start_pos);
579                let (eb, ep) = position_to_byte_coords(buf, end_pos);
580                if eb <= sb {
581                    continue;
582                }
583                out.push(crate::types::ContentEdit {
584                    start_byte: sb,
585                    old_end_byte: eb,
586                    new_end_byte: sb,
587                    start_position: sp,
588                    old_end_position: ep,
589                    new_end_position: sp,
590                });
591            }
592        }
593    }
594
595    out
596}
597
598/// Where the cursor should land in the viewport after a `z`-family
599/// scroll (`zz` / `zt` / `zb`).
600#[derive(Debug, Clone, Copy, PartialEq, Eq)]
601pub enum CursorScrollTarget {
602    Center,
603    Top,
604    Bottom,
605}
606
607// ── Trait-surface cast helpers ────────────────────────────────────
608//
609// 0.0.42 (Patch C-δ.7): the helpers introduced in 0.0.41 were
610// promoted to [`crate::buf_helpers`] so `vim.rs` free fns can route
611// their reaches through the same primitives. Re-import via
612// `use` so the editor body keeps its terse call shape.
613
614use crate::buf_helpers::{
615    apply_buffer_edit, buf_cursor_pos, buf_cursor_rc, buf_cursor_row, buf_line, buf_line_bytes,
616    buf_line_chars, buf_row_count, buf_set_cursor_rc,
617};
618
619use hjkl_buffer::char_col_to_visual_col;
620
621/// Return value from the engine's `try_goto_mark_*` methods. Tells the
622/// caller (app layer) whether a cross-buffer switch is required.
623///
624/// - `SameBuffer` — cursor moved (or mark was unset → no-op) within the
625///   same buffer; no buffer switch needed.
626/// - `CrossBuffer` — the mark lives in a different buffer. The app must
627///   switch to the slot whose `buffer_id` matches, then position the cursor
628///   at `(row, col)` using `Editor::jump_cursor`.
629/// - `Unset` — mark not set; no action needed.
630#[derive(Debug, Clone, PartialEq, Eq)]
631pub enum MarkJump {
632    SameBuffer,
633    CrossBuffer {
634        buffer_id: u64,
635        row: usize,
636        col: usize,
637    },
638    Unset,
639}
640
641/// Uppercase (global) vim marks, keyed by `'A'`–`'Z'`; values are
642/// `(buffer_id, row, col)`. Shared across every window's [`Editor`] via
643/// `Arc<Mutex<GlobalMarks>>` — see [`Editor::set_global_marks_arc`]. Named so
644/// the app host can spell the shared-bank type without repeating the nested
645/// generic (mirrors [`crate::Registers`]).
646pub type GlobalMarks = std::collections::BTreeMap<char, (u64, usize, usize)>;
647
648/// Session-global search state: the last committed `/`/`?` pattern, its
649/// direction, and the search-prompt history. Shared across every window's
650/// [`Editor`] via `Arc<Mutex<SearchBank>>` — see [`Editor::set_search_arc`].
651///
652/// Bundled into one struct behind a single lock (rather than four separate
653/// `Arc<Mutex<_>>` fields) because vim always reads/writes these four
654/// together — e.g. committing a search sets both `last` and `forward` in
655/// the same breath, and `n`/`N` reads both. Mirrors [`GlobalMarks`] /
656/// [`crate::Registers`] as the app host's spelling for the shared-bank type.
657#[derive(Debug, Clone)]
658pub struct SearchBank {
659    /// Last committed search pattern, for `n` / `N` (or Find Next).
660    pub last: Option<String>,
661    /// Direction of the last committed search: `true` = forward (`/`),
662    /// `false` = backward (`?`).
663    pub forward: bool,
664    /// Search history, oldest first. Capped at
665    /// [`crate::types::SEARCH_HISTORY_MAX`] entries.
666    pub history: Vec<String>,
667    /// Cursor while walking search history with Up/Down (Ctrl-P/Ctrl-N).
668    pub history_cursor: Option<usize>,
669}
670
671impl Default for SearchBank {
672    fn default() -> Self {
673        Self {
674            last: None,
675            // Matches vim's default: before any search, `n` behaves as if
676            // the last search were forward.
677            forward: true,
678            history: Vec::new(),
679            history_cursor: None,
680        }
681    }
682}
683
684/// Per-buffer changelist bank: `g;`/`g,` history plus the `'.` / `` `. ``
685/// "last change" mark. Shared via `Arc<Mutex<ChangeBank>>` across every
686/// window's [`Editor`] viewing the SAME buffer — vim's changelist and
687/// last-change mark are per-buffer, not per-window, so an edit made in one
688/// split must be visible to `g;` / `` `. `` from any other split on that
689/// buffer (audit B3).
690///
691/// UNLIKE [`GlobalMarks`] / [`Registers`] / [`SearchBank`] / abbrevs /
692/// last-substitute — which are each a single `Arc` shared by literally
693/// every `Editor` in the app, session-global — a `ChangeBank` is
694/// per-buffer: the app layer keys a bank per `buffer_id` and hands each
695/// `Editor` the Arc for its CURRENT buffer, swapping it whenever the
696/// editor's buffer changes (see `App::change_bank_for` /
697/// `Editor::set_change_bank_arc`). Two editors on the same buffer_id share
698/// one bank; editors on different buffers never see each other's entries.
699#[derive(Debug, Clone, Default)]
700pub struct ChangeBank {
701    /// Position of the most recent buffer mutation, matching vim's `:h '.`
702    /// ("the position where the last change was made" — change-start, not
703    /// the post-edit cursor). Surfaced via the `'.` / `` `. `` marks.
704    pub last_edit: Option<(usize, usize)>,
705    /// Bounded ring of recent edit positions (newest at back). `g;` walks
706    /// toward older, `g,` toward newer. Capped at
707    /// [`crate::types::CHANGE_LIST_MAX`].
708    pub list: Vec<(usize, usize)>,
709    /// Index into `list` while walking; `None` outside a walk (any new
710    /// edit clears it and trims forward entries).
711    pub cursor: Option<usize>,
712    /// `U` (`:h U`) bookkeeping: `(row, text)` — the text of `row` before
713    /// the *first* change landed on it since the tracked row last
714    /// changed. Reset (row + fresh snapshot) whenever an edit's pre-edit
715    /// cursor row differs from the currently tracked row.
716    /// [`Editor::undo_line`] swaps this to the pre-`U` text on each call
717    /// so a second `U` redoes what the first one undid.
718    pub u_line: Option<(usize, String)>,
719    /// Post-edit cursor position of the most recent `mutate_edit` call —
720    /// NOT part of any undo/redo snapshot (deliberately: after an undo/
721    /// redo this goes stale and the next edit correctly starts a fresh
722    /// burst). Lets `mutate_edit` tell whether the *next* edit is a
723    /// continuation of the same typing burst (its pre-edit position
724    /// picks up exactly where this one left the cursor) or the start of
725    /// a new one — see the `entry` comment in `mutate_edit` for why this
726    /// matters for `g;` / `` `. ``: a whole `AXYZ<Esc>` insert session is
727    /// ONE vim change, not three, and `g;` from a fresh cursor lands on
728    /// its start column, not the last-typed character's.
729    pub last_edit_end: Option<(usize, usize)>,
730}
731
732/// RAII guard returned by [`Editor::undo_group`]. Holds the shared `Content`
733/// so it can close its group on `Drop` regardless of how the enclosing scope
734/// exits (normal return, early return, or panic). Dropping the OUTERMOST guard
735/// commits the group's single undo entry (or discards it if the group mutated
736/// nothing); inner guards just decrement the depth. See `push_undo`.
737#[must_use]
738pub struct UndoGroup {
739    content: std::sync::Arc<std::sync::Mutex<hjkl_buffer::Buffer>>,
740}
741
742impl Drop for UndoGroup {
743    fn drop(&mut self) {
744        self.content.lock().unwrap().undo_group_exit();
745    }
746}
747
748pub struct Editor<
749    B: crate::types::View = hjkl_buffer::View,
750    H: crate::types::Host = crate::types::DefaultHost,
751> {
752    /// The installed keyboard discipline's FSM state, type-erased (#265 G3).
753    ///
754    /// The engine never names the concrete type: it only projects a
755    /// [`CoarseMode`] and asks for idle resets through
756    /// [`DisciplineState`]. The owning discipline crate downcasts through
757    /// [`Editor::discipline_mut`] to reach its own state (e.g. `hjkl-vim`'s
758    /// `VimState`).
759    ///
760    /// [`CoarseMode`]: crate::CoarseMode
761    /// [`DisciplineState`]: crate::DisciplineState
762    discipline: Box<dyn crate::DisciplineState>,
763    /// Secondary selections for multi-cursor editing (#63).
764    ///
765    /// The **primary** selection is not in here: its head stays `View::cursor`
766    /// (so the ~130 places across the engine and the disciplines that move the
767    /// cursor keep working untouched) and its anchor lives in the discipline's
768    /// own state (vim's `visual_anchor`, helix's `anchor`). That asymmetry is
769    /// deliberate — see [`crate::selection_shift::Sel`].
770    ///
771    /// Each entry carries BOTH ends, so an operator can act on a *range* at every
772    /// cursor, not just the char under it. [`Editor::mutate_edit`] rewrites both
773    /// ends against the pre-edit geometry after every edit, and drops the whole
774    /// selection if either end becomes untrackable — never half of one.
775    ///
776    /// Char columns, matching `View::cursor` and [`hjkl_buffer::Edit`] — NOT
777    /// the grapheme columns that `types::Pos` uses.
778    ///
779    /// Empty for a single-cursor editor, which is every editor today: vim drives
780    /// one caret, so this costs an `is_empty()` check per edit and nothing else.
781    extra_selections: Vec<crate::selection_shift::Sel>,
782    /// Read-only view overlay (git blame, …) layered over the input mode.
783    /// Discipline-agnostic engine substrate (#265 G3): hoisted out of
784    /// `VimState` because the core edit funnel (`mutate_edit`) and render/chrome
785    /// (`is_blame`/`view_mode`) read it, and any discipline can present an
786    /// overlay. Orthogonal to the input mode; auto-reset to `Normal` whenever
787    /// the input mode leaves Normal (see `drop_blame_if_left_normal`).
788    pub(crate) view: crate::ViewMode,
789    /// The changelist / last-change-mark bank: `last_edit`, `list`,
790    /// `cursor`. Discipline-agnostic substrate (#265 G3): the engine-core
791    /// edit path (`mutate_edit`) writes it and any discipline can offer
792    /// "back to last edit" / `g;`/`g,`.
793    ///
794    /// Shared via `Arc<Mutex<_>>` — but PER-BUFFER, not session-global like
795    /// [`Editor::global_marks`] / [`Editor::registers`] (audit B3): vim's
796    /// changelist and `` `. `` mark are per-buffer, so two windows/splits on
797    /// the SAME buffer must see one shared changelist, while windows on
798    /// DIFFERENT buffers must stay isolated. The app layer keys a bank per
799    /// `buffer_id` and swaps this Arc via [`Editor::set_change_bank_arc`]
800    /// whenever the editor's buffer changes. See [`ChangeBank`].
801    pub(crate) change_bank: std::sync::Arc<std::sync::Mutex<ChangeBank>>,
802    /// Undo history: each entry is `(joined_document, cursor)` before the
803    /// edit. Stored as `Arc<String>` so it shares the
804    /// Undo history: snapshots taken via `View::rope()` — `ropey::Rope::clone`
805    /// is O(1) (Arc-clone of the B-tree root). Previously stored
806    /// `Arc<String>` from `content_joined()`, which on the rope storage
807    /// builds the entire document `String` via `rope.to_string()` — that
808    /// turned every `i` / `o` keystroke into a ~3 MB allocation on a
809    /// 1.86 M-line file.
810    // undo_stack, redo_stack, content_dirty, cached_content (as
811    // cached_editor_content), pending_fold_ops, change_log,
812    // pending_content_edits, pending_content_reset are now stored on
813    // Buffer (inside self.buffer) and accessed via View accessor methods.
814    /// Last rendered viewport height (text rows only, no chrome). Written
815    /// by the draw path via [`set_viewport_height`] so the scroll helpers
816    /// can clamp the cursor to stay visible without plumbing the height
817    /// through every call.
818    pub(super) viewport_height: AtomicU16,
819    /// Pending LSP intent set by a normal-mode chord (e.g. `gd` for
820    /// goto-definition). The host app drains this each step and fires
821    /// the matching request against its own LSP client.
822    pub(super) pending_lsp: Option<LspIntent>,
823    /// Re-entrancy guard for [`Editor::undo_line`] (`U`): while its own
824    /// line-replacing edits run through [`Editor::mutate_edit`], the
825    /// generic `ChangeBank::u_line` auto-snapshot logic must NOT treat
826    /// them as a fresh "first change on this row" — `undo_line` manages
827    /// the swap itself.
828    pub(super) suppress_u_line_track: bool,
829    /// View storage.
830    ///
831    /// 0.1.0 (Patch C-δ): generic over `B: View` per SPEC §"Editor
832    /// surface". Default `B = hjkl_buffer::View`. The vim FSM body
833    /// and `Editor::mutate_edit` are concrete on `hjkl_buffer::View`
834    /// for 0.1.0 — see `crate::buf_helpers::apply_buffer_edit`.
835    pub(super) buffer: B,
836    /// Engine-native style intern table. Opaque `Span::style` ids index
837    /// into this table; the render path resolves ids back to
838    /// [`crate::types::Style`]. Ratatui hosts convert at the boundary via
839    /// `hjkl_engine_tui::style_to_ratatui`. Always present — no cfg-mutex.
840    pub(super) style_table: Vec<crate::types::Style>,
841    /// Vim-style register bank — `"`, `"0`–`"9`, `"a`–`"z`. Sources
842    /// every `p` / `P` via the active selector (default unnamed).
843    /// Internal — read via [`Editor::registers`]; mutated by yank /
844    /// delete / paste FSM paths and by [`Editor::seed_yank`].
845    pub(crate) registers: std::sync::Arc<std::sync::Mutex<crate::registers::Registers>>,
846    /// Per-row syntax styling in engine-native form. Always present —
847    /// populated by [`Editor::install_syntax_spans`]. Ratatui hosts use
848    /// `hjkl_engine_tui::EditorRatatuiExt::install_ratatui_syntax_spans`.
849    pub styled_spans: Vec<Vec<(usize, usize, crate::types::Style)>>,
850    /// Per-editor settings tweakable via `:set`. Exposed by reference
851    /// so handlers (indent, search) read the live value rather than a
852    /// snapshot taken at startup. Read via [`Editor::settings`];
853    /// mutate via [`Editor::settings_mut`].
854    pub(crate) settings: Settings,
855    /// Global (uppercase) marks that carry a `buffer_id` so they can jump
856    /// across buffers. Keyed by `'A'`–`'Z'`; values are
857    /// `(buffer_id, row, col)`. Set by `m{A-Z}`, resolved by
858    /// `try_goto_mark_line` / `try_goto_mark_char`.
859    ///
860    /// Shared via `Arc<Mutex<_>>` across every window's `Editor` (mirrors
861    /// [`Editor::registers`]) — vim's uppercase marks are session-global, so
862    /// setting `mA` in one split and jumping `'A` from another must see the
863    /// same map. Internal — read/mutated via [`Editor::global_mark`] /
864    /// [`Editor::set_global_mark`] / [`Editor::global_marks_iter`]; wired by
865    /// [`Editor::set_global_marks_arc`].
866    pub(crate) global_marks: std::sync::Arc<std::sync::Mutex<GlobalMarks>>,
867
868    // ── Navigation history / viewport (discipline-agnostic, #265) ────────────
869    //
870    // Hoisted off `VimState` because they are not vim concepts: a jumplist is
871    // navigation history (VSCode's Go Back / Go Forward wants the same list),
872    // and the viewport flags are render state. A future helix/vscode
873    // discipline needs these without depending on hjkl-vim, so they live on
874    // the engine seam.
875    /// Positions pushed on "big" motions. Newest at the back — `Ctrl-o` pops
876    /// from here.
877    pub(crate) jump_back: Vec<(usize, usize)>,
878    /// Forward stack, refilled by `Ctrl-o` so `Ctrl-i` can return.
879    pub(crate) jump_fwd: Vec<(usize, usize)>,
880    /// When set, the viewport does not scroll-follow the cursor.
881    pub(crate) viewport_pinned: bool,
882    /// One-shot hint that the last scroll should be animated by the renderer.
883    pub(crate) scroll_anim_hint: bool,
884
885    // ── Search state (discipline-agnostic, #265) ─────────────────────────────
886    //
887    // Every editor has find. A vscode/helix discipline needs the pattern,
888    // direction and history without depending on hjkl-vim.
889    /// Live `/` or `?` prompt while the user is typing a pattern.
890    pub(crate) search_prompt: Option<crate::search::SearchPrompt>,
891    /// Last committed search pattern + direction + history (the `"/`
892    /// register), bundled into [`SearchBank`].
893    ///
894    /// Shared via `Arc<Mutex<_>>` across every window's `Editor` (mirrors
895    /// [`Editor::global_marks`]) — vim's last search is session-global, so
896    /// `/foo<CR>` in one split and `n` in another must see the same
897    /// pattern. Internal — read/mutated via [`Editor::last_search`] /
898    /// [`Editor::set_last_search`] / friends; wired by
899    /// [`Editor::set_search_arc`].
900    pub(crate) search: std::sync::Arc<std::sync::Mutex<SearchBank>>,
901
902    // ── Input timing (discipline-agnostic) ───────────────────────────────────
903    //
904    // Any chorded FSM needs a timeout clock, not just vim.
905    /// Instant of the last input, when the host supplies a monotonic clock.
906    pub(crate) last_input_at: Option<std::time::Instant>,
907    /// Host-supplied elapsed time at the last input (no_std hosts).
908    pub(crate) last_input_host_at: Option<core::time::Duration>,
909
910    /// Last `:s` command, for `:&` / `:&&`. This is ex-command state owned by
911    /// the hjkl-ex seam, not vim FSM state.
912    ///
913    /// Shared via `Arc<Mutex<_>>` across every window's `Editor` (mirrors
914    /// [`Editor::global_marks`]) — vim's last substitute is session-global,
915    /// so running `:s` in one split and `:&` in another must see the same
916    /// command. Internal — read/mutated via [`Editor::last_substitute`] /
917    /// [`Editor::set_last_substitute`]; wired by
918    /// [`Editor::set_last_substitute_arc`].
919    pub(crate) last_substitute:
920        std::sync::Arc<std::sync::Mutex<Option<crate::substitute::SubstituteCmd>>>,
921
922    // ── Autopair / abbreviations (discipline-agnostic, #265) ─────────────────
923    //
924    // Neither is a vim concept. Autopair is an editor feature gated by
925    // `Settings::autopair` (VSCode has it too), and the abbreviation table is
926    // driven by hjkl-ex's `:abbreviate` / `:iabbrev` — hjkl-ex is in fact the
927    // only caller of the add/remove/clear accessors.
928    /// Close-brackets queued by autopair, as `(row, col, ch)`. Typing the
929    /// matching close char consumes the queued one instead of inserting.
930    pub(crate) pending_closes: Vec<(usize, usize, char)>,
931    /// Active abbreviation table (insert-mode + cmdline entries).
932    ///
933    /// Shared via `Arc<Mutex<_>>` across every window's `Editor` (mirrors
934    /// [`Editor::last_substitute`]) — vim's abbreviations are session-global,
935    /// so `:iabbrev` defined in one split must expand in every other split.
936    /// Internal — read/mutated via [`Editor::abbrevs`] / [`Editor::add_abbrev`]
937    /// / [`Editor::remove_abbrev`] / [`Editor::clear_abbrevs`]; wired by
938    /// [`Editor::set_abbrevs_arc`].
939    pub(crate) abbrevs: std::sync::Arc<std::sync::Mutex<Vec<crate::abbrev::Abbrev>>>,
940
941    /// Whether the unnamed register's current content is linewise. This is
942    /// register metadata, not vim FSM state — any discipline that yanks and
943    /// pastes needs it (#265).
944    ///
945    /// Deliberately per-window, NOT shared via `Arc` (#279 slice 4
946    /// investigation): it is transient scratch state saved/restored around a
947    /// single operator (see `visual_ops.rs`, `text_object_ops.rs`), not the
948    /// source of truth for paste. The actual paste decision (`do_paste` in
949    /// hjkl-vim/src/vim/command.rs) reads `linewise` off the *selected
950    /// register slot* — which already lives in the shared `registers` Arc
951    /// above — so a whole-line yank in one window correctly pastes linewise
952    /// in a sibling window without this field needing to be shared too.
953    pub(crate) yank_linewise: bool,
954
955    /// The `buffer_id` this editor instance is currently attached to.
956    /// Updated by the host app on every `switch_to` / slot creation so
957    /// global-mark writes record the correct id without requiring the app
958    /// to pass the id on every keystroke.
959    pub(crate) current_buffer_id: u64,
960    // change_log moved to Buffer; accessed via self.buffer.take_change_log() etc.
961    /// Vim's "sticky column" (curswant). `None` before the first
962    /// motion — the next vertical motion bootstraps from the live
963    /// cursor column. Horizontal motions refresh this to the new
964    /// column; vertical motions read it back so bouncing through a
965    /// shorter row doesn't drag the cursor to col 0. Hoisted out of
966    /// `hjkl_buffer::View` (and `VimState`) in 0.0.28 — Editor is
967    /// the single owner now. View motion methods that need it
968    /// take a `&mut Option<usize>` parameter.
969    pub(crate) sticky_col: Option<usize>,
970    /// Host adapter for clipboard, cursor-shape, time, viewport, and
971    /// search-prompt / cancellation side-channels.
972    ///
973    /// 0.1.0 (Patch C-δ): generic over `H: Host` per SPEC §"Editor
974    /// surface". Default `H = DefaultHost`. The pre-0.1.0 `EngineHost`
975    /// dyn-shim is gone — every method now dispatches through `H`'s
976    /// `Host` trait surface directly.
977    pub(crate) host: H,
978    /// Last public mode the cursor-shape emitter saw. Drives
979    /// [`Editor::emit_cursor_shape_if_changed`] so `Host::emit_cursor_shape`
980    /// fires exactly once per mode transition without sprinkling the
981    /// call across every `vim.mode = ...` site.
982    pub(crate) last_emitted_mode: crate::CoarseMode,
983    /// Search FSM state (pattern + per-row match cache + wrapscan).
984    /// 0.0.35: relocated out of `hjkl_buffer::View` per
985    /// `DESIGN_33_METHOD_CLASSIFICATION.md` step 1.
986    /// 0.0.37: the buffer-side bridge (`View::search_pattern`) is
987    /// gone; `BufferView` now takes the active regex as a `&Regex`
988    /// parameter, sourced from `Editor::search_state().pattern`.
989    pub(crate) search_state: crate::search::SearchState,
990    /// Per-row syntax span overlay. Source of truth for the host's
991    /// renderer ([`hjkl_buffer::BufferView::spans`]). Populated by
992    /// [`Editor::install_syntax_spans`] (ratatui hosts use
993    /// `hjkl_engine_tui::EditorRatatuiExt::install_ratatui_syntax_spans`)
994    /// and, in due course, by `Host::syntax_highlights` once the engine
995    /// drives that path directly.
996    ///
997    /// 0.0.37: lifted out of `hjkl_buffer::View` per step 3 of
998    /// `DESIGN_33_METHOD_CLASSIFICATION.md`. The buffer-side cache +
999    /// `View::set_spans` / `View::spans` accessors are gone.
1000    pub(crate) buffer_spans: Vec<Vec<hjkl_buffer::Span>>,
1001    // pending_content_edits and pending_content_reset moved to Buffer;
1002    // accessed via self.buffer.take_pending_content_edits() etc.
1003    /// Row range touched by the most recent `auto_indent_rows` call.
1004    /// `(top_row, bot_row)` inclusive. Set by the engine after every
1005    /// auto-indent operation; drained (and cleared) by the host via
1006    /// [`Editor::take_last_indent_range`] so it can display a brief
1007    /// visual flash over the reindented rows.
1008    pub(crate) last_indent_range: Option<(usize, usize)>,
1009}
1010
1011/// Vim-style options surfaced by `:set`. New fields land here as
1012/// individual ex commands gain `:set` plumbing.
1013#[derive(Debug, Clone)]
1014pub struct Settings {
1015    /// Spaces per shift step for `>>` / `<<` / `Ctrl-T` / `Ctrl-D`.
1016    pub shiftwidth: usize,
1017    /// Visual width of a `\t` character. Stored for future render
1018    /// hookup; not yet consumed by the buffer renderer.
1019    pub tabstop: usize,
1020    /// When true, `/` / `?` patterns and `:s/.../.../` ignore case
1021    /// without an explicit `i` flag.
1022    pub ignore_case: bool,
1023    /// When true *and* `ignore_case` is true, an uppercase letter in
1024    /// the pattern flips that search back to case-sensitive. Matches
1025    /// vim's `:set smartcase`. Default `false`.
1026    pub smartcase: bool,
1027    /// Highlight every match of the armed search pattern. Matches vim's
1028    /// `:set hlsearch`. Default `true`.
1029    ///
1030    /// Distinct from `:nohlsearch`, which disarms the *pattern* for this
1031    /// search; this suppresses the highlight while leaving the pattern armed,
1032    /// so `n` / `N` keep working. The host reads it when building the render
1033    /// frame.
1034    pub hlsearch: bool,
1035    /// Highlight matches as the search pattern is typed, before it is
1036    /// submitted. Matches vim's `:set incsearch`. Default `true`. The host
1037    /// reads it in its search-prompt live-preview path.
1038    pub incsearch: bool,
1039    /// Honour a `vim:` / `ex:` / `vi:` modeline in files as they are opened.
1040    /// Matches vim's `:set modeline`. Default `true`.
1041    ///
1042    /// Read by the host when it opens a buffer, so changing it affects
1043    /// **subsequently** opened files — vim's behaviour, since the scan has
1044    /// already happened for a file that is open.
1045    pub modeline: bool,
1046    /// How many lines at each end of a file are scanned for a modeline.
1047    /// Matches vim's `:set modelines`. Default `5`.
1048    pub modelines: u32,
1049    /// Wrap searches past buffer ends. Matches vim's `:set wrapscan`.
1050    /// Default `true`.
1051    pub wrapscan: bool,
1052    /// Wrap column for `gq{motion}` text reflow. Vim's default is 79.
1053    pub textwidth: usize,
1054    /// When `true`, the Tab key in insert mode inserts `tabstop` spaces
1055    /// instead of a literal `\t`. Matches vim's `:set expandtab`.
1056    /// Default `false`.
1057    pub expandtab: bool,
1058    /// Soft tab stop in spaces. When `> 0`, Tab inserts spaces to the
1059    /// next softtabstop boundary (when `expandtab`), and Backspace at the
1060    /// end of a softtabstop-aligned space run deletes the entire run as
1061    /// if it were one tab. `0` disables. Matches vim's `:set softtabstop`.
1062    pub softtabstop: usize,
1063    /// Soft-wrap mode the renderer + scroll math + `gj` / `gk` use.
1064    /// Default is [`hjkl_buffer::Wrap::None`] — long lines extend
1065    /// past the right edge and `top_col` clips the left side.
1066    /// `:set wrap` flips to char-break wrap; `:set linebreak` flips
1067    /// to word-break wrap; `:set nowrap` resets.
1068    pub wrap: hjkl_buffer::Wrap,
1069    /// When true, the engine drops every edit before it touches the
1070    /// buffer — undo, dirty flag, and change log all stay clean.
1071    /// Matches vim's `:set readonly` / `:set ro`. Default `false`.
1072    pub readonly: bool,
1073    /// When `false`, ALL buffer modifications are blocked, including entering
1074    /// insert/replace mode. Matches vim's `:set nomodifiable` / `:set noma`.
1075    /// Default `true`.
1076    pub modifiable: bool,
1077    /// When `true`, pressing Enter in insert mode copies the leading
1078    /// whitespace of the current line onto the new line. Matches vim's
1079    /// `:set autoindent`. Default `true` (vim parity).
1080    pub autoindent: bool,
1081    /// When `true`, bumps indent by one `shiftwidth` after a line ending
1082    /// in `{` / `(` / `[`, and strips one indent unit when the user types
1083    /// `}` / `)` / `]` on a whitespace-only line. See `compute_enter_indent`
1084    /// in `vim.rs` for the tree-sitter plug-in seam. Default `true`.
1085    pub smartindent: bool,
1086    /// Cap on undo-stack length. Older entries are pruned past this
1087    /// bound. `0` means unlimited. Matches vim's `:set undolevels`.
1088    /// Default `1000`.
1089    pub undo_levels: u32,
1090    /// When `true`, cursor motions inside insert mode break the
1091    /// current undo group (so a single `u` only reverses the run of
1092    /// keystrokes that preceded the motion). Default `true`.
1093    /// Currently a no-op — engine doesn't yet break the undo group
1094    /// on insert-mode motions; field is wired through `:set
1095    /// undobreak` for forward compatibility.
1096    pub undo_break_on_motion: bool,
1097    /// Vim-flavoured "what counts as a word" character class.
1098    /// Comma-separated tokens: `@` = `is_alphabetic()`, `_` = literal
1099    /// `_`, `48-57` = decimal char range, bare integer = single char
1100    /// code, single ASCII punctuation = literal. Default
1101    /// `"@,48-57,_,192-255"` matches vim.
1102    pub iskeyword: String,
1103    /// Multi-key sequence timeout (e.g. `gg`, `dd`). When the user
1104    /// pauses longer than this between keys, any pending prefix is
1105    /// abandoned and the next key starts a fresh sequence. Matches
1106    /// vim's `:set timeoutlen` / `:set tm` (millis). Default 1000ms.
1107    pub timeout_len: core::time::Duration,
1108    /// When true, render absolute line numbers in the gutter. Matches
1109    /// vim's `:set number` / `:set nu`. Default `true`.
1110    pub number: bool,
1111    /// When true, render line numbers as offsets from the cursor row.
1112    /// Combined with `number`, the cursor row shows its absolute number
1113    /// while other rows show the relative offset (vim's `nu+rnu` hybrid).
1114    /// Matches vim's `:set relativenumber` / `:set rnu`. Default `false`.
1115    pub relativenumber: bool,
1116    /// Minimum gutter width in cells for the line-number column.
1117    /// Width grows past this to fit the largest displayed number.
1118    /// Matches vim's `:set numberwidth` / `:set nuw`. Default `4`.
1119    /// Range 1..=20.
1120    pub numberwidth: usize,
1121    /// Highlight the row where the cursor sits. Matches vim's `:set cursorline`.
1122    ///
1123    /// Default `true` — a deliberate hjkl divergence from vim, which defaults
1124    /// to `nocursorline`. Must stay in lockstep with
1125    /// [`crate::types::Options::default`].
1126    pub cursorline: bool,
1127    /// Highlight the column where the cursor sits. Matches vim's `:set cursorcolumn`.
1128    /// Default `false` — vim parity (`nocursorcolumn`).
1129    pub cursorcolumn: bool,
1130    /// Sign-column display mode. Matches vim's `:set signcolumn`.
1131    /// Default [`crate::types::SignColumnMode::Auto`].
1132    pub signcolumn: crate::types::SignColumnMode,
1133    /// Number of cells reserved for a fold-marker gutter.
1134    /// Matches vim's `:set foldcolumn`. Default `0`.
1135    pub foldcolumn: u32,
1136    /// How folds are automatically generated. Default `Expr` (tree-sitter).
1137    /// Alias `fdm`. Matches vim's `:set foldmethod`.
1138    pub foldmethod: crate::types::FoldMethod,
1139    /// Enable automatic folds. Default `true`. Alias `fen`.
1140    /// Matches vim's `:set foldenable`.
1141    pub foldenable: bool,
1142    /// Level at which auto-folds start open. `99` = all open (default). Alias `fls`.
1143    /// Matches vim's `:set foldlevelstart`.
1144    pub foldlevelstart: u32,
1145    /// Open/close markers for `foldmethod=marker`, comma-separated `open,close`.
1146    /// Matches vim's `:set foldmarker` / `fmr`. Default `"{{{,}}}"`.
1147    pub foldmarker: String,
1148    /// Comma-separated 1-based column indices for vertical rulers.
1149    /// Matches vim's `:set colorcolumn`. Default `""`.
1150    pub colorcolumn: String,
1151    /// Format options flags (subset of vim's `formatoptions`).
1152    /// `r` — auto-continue line comments on `<Enter>` in insert mode.
1153    /// `o` — auto-continue line comments on `o` / `O` in normal mode.
1154    /// Default: both on (`"ro"`).
1155    pub formatoptions: String,
1156    /// Active filetype (language name) for the current buffer.
1157    /// Used by comment-continuation and future language-aware features.
1158    /// Matches vim's `:set filetype` / `:set ft`. Default `""` (plain text).
1159    pub filetype: String,
1160    /// Override comment-string for the current buffer.
1161    ///
1162    /// When non-empty, used by `toggle_comment_range` instead of the
1163    /// per-filetype default from `hjkl_lang::comment::commentstring_for_lang`.
1164    /// Follows vim's `:set commentstring=…` — use `%s` as the text placeholder
1165    /// (e.g. `"// %s"`) for compatibility; the toggle strips/inserts only the
1166    /// prefix/suffix portion (before/after `%s`).  An empty string means "use
1167    /// the filetype default".  Default `""`.
1168    pub commentstring: String,
1169    /// Program run by `:make` (vim's `makeprg`). Its stdout+stderr are parsed
1170    /// via the errorformat into the quickfix list. Default `"cargo check"`.
1171    pub makeprg: String,
1172    /// Comma-separated list of errorformat patterns used by `:cexpr` /
1173    /// `:lgetexpr` etc. to parse text into quickfix entries. Follows vim's
1174    /// `'errorformat'` / `'efm'`. Default: `"%f:%l:%c:%m,%f:%l:%m,%l:%c:%m"`.
1175    pub errorformat: String,
1176    /// When `true`, typing an opening bracket or quote automatically inserts
1177    /// the matching close character and parks the cursor between them.
1178    /// Matches vim's `set autopairs` (Neovim) / nvim-autopairs behaviour.
1179    /// Default `true`.
1180    pub autopair: bool,
1181    /// When `true`, typing `>` to close an HTML/XML opening tag automatically
1182    /// inserts `</tagname>` after the cursor. Only fires for filetypes in the
1183    /// HTML/XML family (`html`, `xml`, `svg`, `jsx`, `tsx`, `vue`, `svelte`).
1184    /// Matches common editor "autoclose tag" behaviour. Default: `true` for
1185    /// those filetypes (the caller gates on filetype), `true` stored here so
1186    /// `:set noautoclose-tag` can disable it globally.
1187    pub autoclose_tag: bool,
1188    /// Minimum context rows kept visible above/below the cursor when scrolling.
1189    /// Capped at (height - 1) / 2 for tiny viewports. `0` = no margin.
1190    /// Matches vim's `:set scrolloff` / `:set so`. Default `5`.
1191    pub scrolloff: usize,
1192    /// Minimum context columns kept visible left/right of the cursor (no-wrap
1193    /// mode only). `0` = no margin (vim default). Matches `:set sidescrolloff`.
1194    /// Default `0`.
1195    pub sidescrolloff: usize,
1196    /// Auto-reload a clean buffer when its file changes on disk. Matches vim's
1197    /// `:set autoread`. Default `true`. Consumed by the host's `:checktime`.
1198    pub autoreload: bool,
1199    /// Enable vim-sneak style two-char digraph jump via `s` (forward) and
1200    /// `S` (backward). When `true` (default), `s`/`S` no longer behave as
1201    /// vim's built-in substitute-char / substitute-line; `;`/`,` smart-fall-
1202    /// back to sneak-repeat when the last horizontal motion was a sneak.
1203    /// Set `:set nomotion_sneak` to revert `s`/`S` to stock vim behavior.
1204    /// Default `true` — **BREAKING** for users relying on `s` = substitute-char.
1205    pub motion_sneak: bool,
1206    /// Render invisible characters (tabs, trailing spaces, EOL markers).
1207    /// Matches vim's `:set list` / `:set nolist`. Default `false`.
1208    pub list: bool,
1209    /// Show Nerd-Font filetype icons in the tabline. `:set tabline_icons` /
1210    /// `:set notabline_icons`. Default `true`.
1211    pub tabline_icons: bool,
1212    /// Show inline git blame as end-of-line virtual text on the cursor line
1213    /// (gitsigns-style). Default `true`. (#202)
1214    pub blame_inline: bool,
1215    /// Inline diagnostic ghost-text mode (Error-Lens style `// message` at the
1216    /// end of the line). Default [`crate::types::DiagInlineMode::All`].
1217    pub diagnostics_inline: crate::types::DiagInlineMode,
1218    /// Characters used to represent invisibles when `list` is on.
1219    /// Matches vim's `:set listchars` / `:set lcs`.
1220    pub listchars: crate::types::ListChars,
1221    /// Render thin vertical indent guides at every `shiftwidth`-aligned
1222    /// column. hjkl-specific. Default `true`.
1223    pub indent_guides: bool,
1224    /// Character used to draw indent guides. Default `'│'`.
1225    pub indent_guide_char: char,
1226    /// Enable inline color-literal preview. hjkl-specific. Default `true`.
1227    pub colorizer: bool,
1228    /// Filetype allowlist for the colorizer. Default CSS/template languages.
1229    pub colorizer_filetypes: Vec<String>,
1230    /// Run hjkl-mangler formatter before each `:w` save. Default `false`.
1231    pub format_on_save: bool,
1232    /// Strip trailing whitespace before each `:w` save. Default `false`.
1233    pub trim_trailing_whitespace: bool,
1234    /// Enable helix-style rainbow bracket coloring. hjkl-specific. Default `true`.
1235    pub rainbow_brackets: bool,
1236    /// Milliseconds of inactivity before swap-file write. Default `4000`.
1237    /// Matches Vim's `updatetime`; alias `ut`.
1238    pub updatetime: u32,
1239    /// Highlight matching bracket pair under the cursor. hjkl-specific. Default `true`.
1240    /// `:set nomatchparen` / `:set mps` to toggle. Only the char-scan path
1241    /// (C-style brackets) is active; tag-pair matching is pending #240.
1242    pub matchparen: bool,
1243    /// Vim `'fixendofline'` / `'fixeol'`. Default `true`: add the missing
1244    /// final newline on write. See [`crate::types::Options::fixendofline`] —
1245    /// its buffer-local partner `'endofline'` is host state, not a setting.
1246    pub fixendofline: bool,
1247    /// Smooth-scroll animation duration for page/recenter motions, ms.
1248    /// `:set scroll_duration_ms`. Default `0` (instant — animation off).
1249    pub scroll_duration_ms: u16,
1250    /// When `true`, char-wise Visual selections are treated as
1251    /// **half-open** (exclusive end): the cell at the cursor/head position
1252    /// is NOT included in the selection. This matches VSCode / kakoune
1253    /// bar-cursor semantics where the caret sits *between* characters.
1254    /// Default `false` (vim inclusive). The vim oracle path must leave this
1255    /// at `false`; set it programmatically for VSCode keybinding mode.
1256    pub selection_exclusive: bool,
1257    /// How coarsely a single `u` (or Ctrl+Z) step walks back through
1258    /// changes made during an insert session.
1259    ///
1260    /// - `InsertSession` (default, vim parity): one undo step reverts the
1261    ///   entire session from `i` to `<Esc>`. This is byte-identical to
1262    ///   vim's behaviour and must never be changed for the vim path.
1263    /// - `Word`: mid-session undo breaks are inserted at word boundaries
1264    ///   (non-whitespace char following whitespace, or a newline). One
1265    ///   step of `u` then reverts roughly one word of typing at a time —
1266    ///   matching VSCode's "edit-chunked Ctrl+Z" experience.
1267    ///
1268    /// The vim oracle path **must** leave this at `InsertSession`.
1269    /// VSCode keybinding mode sets it to `Word` via
1270    /// `propagate_vscode_settings`. Other future FSMs may choose freely.
1271    pub undo_granularity: UndoGranularity,
1272}
1273
1274/// Controls the granularity of per-insert-session undo steps.
1275///
1276/// Discipline-agnostic: vim uses `InsertSession`, VSCode uses `Word`.
1277/// Future FSMs (emacs, kakoune, …) may adopt either or add new variants.
1278#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1279pub enum UndoGranularity {
1280    /// One `u` step reverts the entire insert session (vim default).
1281    #[default]
1282    InsertSession,
1283    /// Mid-session undo breaks at word boundaries (non-whitespace after
1284    /// whitespace, or newline). Matches VSCode's Ctrl+Z granularity.
1285    Word,
1286}
1287
1288/// Translate the engine-internal soft-wrap mode into the SPEC one.
1289///
1290/// The two enums are 1:1; this exists so the mapping lives in exactly one
1291/// place instead of being copy-pasted at every `Settings` ↔ `Options`
1292/// boundary. Inverse of [`wrap_from_mode`].
1293fn wrap_to_mode(wrap: hjkl_buffer::Wrap) -> crate::types::WrapMode {
1294    match wrap {
1295        hjkl_buffer::Wrap::None => crate::types::WrapMode::None,
1296        hjkl_buffer::Wrap::Char => crate::types::WrapMode::Char,
1297        hjkl_buffer::Wrap::Word => crate::types::WrapMode::Word,
1298    }
1299}
1300
1301/// Translate a SPEC soft-wrap mode into the engine-internal one.
1302/// Inverse of [`wrap_to_mode`].
1303fn wrap_from_mode(mode: crate::types::WrapMode) -> hjkl_buffer::Wrap {
1304    match mode {
1305        crate::types::WrapMode::None => hjkl_buffer::Wrap::None,
1306        crate::types::WrapMode::Char => hjkl_buffer::Wrap::Char,
1307        crate::types::WrapMode::Word => hjkl_buffer::Wrap::Word,
1308    }
1309}
1310
1311impl Default for Settings {
1312    fn default() -> Self {
1313        Self {
1314            shiftwidth: 4,
1315            tabstop: 4,
1316            softtabstop: 4,
1317            ignore_case: true,
1318            smartcase: true,
1319            hlsearch: true,
1320            incsearch: true,
1321            modeline: true,
1322            modelines: 5,
1323            wrapscan: true,
1324            textwidth: 79,
1325            expandtab: true,
1326            wrap: hjkl_buffer::Wrap::None,
1327            readonly: false,
1328            modifiable: true,
1329            autoindent: true,
1330            smartindent: true,
1331            undo_levels: 1000,
1332            undo_break_on_motion: true,
1333            iskeyword: "@,48-57,_,192-255".to_string(),
1334            timeout_len: core::time::Duration::from_millis(1000),
1335            number: true,
1336            relativenumber: false,
1337            numberwidth: 4,
1338            cursorline: true,
1339            cursorcolumn: false,
1340            signcolumn: crate::types::SignColumnMode::Auto,
1341            foldcolumn: 0,
1342            foldmethod: crate::types::FoldMethod::Expr,
1343            foldenable: true,
1344            foldlevelstart: 99,
1345            foldmarker: "{{{,}}}".to_string(),
1346            colorcolumn: String::new(),
1347            formatoptions: "ro".to_string(),
1348            filetype: String::new(),
1349            commentstring: String::new(),
1350            makeprg: "cargo check".to_string(),
1351            errorformat: "%f:%l:%c:%m,%f:%l:%m,%l:%c:%m".to_string(),
1352            autopair: true,
1353            autoclose_tag: true,
1354            scrolloff: 5,
1355            sidescrolloff: 0,
1356            autoreload: true,
1357            motion_sneak: true,
1358            list: false,
1359            tabline_icons: true,
1360            blame_inline: true,
1361            diagnostics_inline: crate::types::DiagInlineMode::All,
1362            listchars: crate::types::ListChars::default(),
1363            indent_guides: true,
1364            indent_guide_char: '│',
1365            colorizer: true,
1366            colorizer_filetypes: vec![
1367                "css".to_string(),
1368                "scss".to_string(),
1369                "sass".to_string(),
1370                "less".to_string(),
1371                "html".to_string(),
1372                "vue".to_string(),
1373                "svelte".to_string(),
1374                "tailwindcss".to_string(),
1375                "toml".to_string(),
1376                "lua".to_string(),
1377                "vim".to_string(),
1378            ],
1379            format_on_save: true,
1380            trim_trailing_whitespace: false,
1381            rainbow_brackets: true,
1382            updatetime: 4000,
1383            matchparen: true,
1384            fixendofline: true,
1385            scroll_duration_ms: 0,
1386            selection_exclusive: false,
1387            undo_granularity: UndoGranularity::InsertSession,
1388        }
1389    }
1390}
1391
1392impl Settings {
1393    /// Read these settings as a SPEC [`crate::types::Options`] snapshot.
1394    /// Pure [`Settings`] surface — usable without an [`Editor`] (#151 Phase
1395    /// D / Stage 2b: `BufferSlot` holds a bare `Settings` template for
1396    /// windowless slots). [`Editor::current_options`] delegates here.
1397    ///
1398    /// **Exhaustive by construction** — every [`crate::types::Options`] field
1399    /// is listed explicitly, with no `..Options::default()` backfill. That
1400    /// matters because callers do read-modify-apply through this seam
1401    /// (`current_options()` → tweak one field → `apply_options()`); a
1402    /// default-filled field would silently reset every option the caller
1403    /// didn't touch. [`Settings::apply_options`] is the exact inverse: adding
1404    /// a field to one without the other breaks the round trip pinned by
1405    /// `settings_options_round_trip_is_identity`.
1406    ///
1407    pub fn to_options(&self) -> crate::types::Options {
1408        crate::types::Options {
1409            tabstop: self.tabstop as u32,
1410            shiftwidth: self.shiftwidth as u32,
1411            expandtab: self.expandtab,
1412            softtabstop: self.softtabstop as u32,
1413            iskeyword: self.iskeyword.clone(),
1414            ignorecase: self.ignore_case,
1415            smartcase: self.smartcase,
1416            hlsearch: self.hlsearch,
1417            incsearch: self.incsearch,
1418            wrapscan: self.wrapscan,
1419            autoindent: self.autoindent,
1420            smartindent: self.smartindent,
1421            timeout_len: self.timeout_len,
1422            undo_levels: self.undo_levels,
1423            undo_break_on_motion: self.undo_break_on_motion,
1424            readonly: self.readonly,
1425            modifiable: self.modifiable,
1426            wrap: wrap_to_mode(self.wrap),
1427            textwidth: self.textwidth as u32,
1428            number: self.number,
1429            relativenumber: self.relativenumber,
1430            numberwidth: self.numberwidth,
1431            cursorline: self.cursorline,
1432            cursorcolumn: self.cursorcolumn,
1433            signcolumn: self.signcolumn,
1434            foldcolumn: self.foldcolumn,
1435            foldmethod: self.foldmethod,
1436            foldenable: self.foldenable,
1437            foldlevelstart: self.foldlevelstart,
1438            foldmarker: self.foldmarker.clone(),
1439            colorcolumn: self.colorcolumn.clone(),
1440            formatoptions: self.formatoptions.clone(),
1441            filetype: self.filetype.clone(),
1442            scrolloff: self.scrolloff,
1443            sidescrolloff: self.sidescrolloff,
1444            modeline: self.modeline,
1445            modelines: self.modelines,
1446            autoreload: self.autoreload,
1447            motion_sneak: self.motion_sneak,
1448            list: self.list,
1449            listchars: self.listchars.clone(),
1450            indent_guides: self.indent_guides,
1451            indent_guide_char: self.indent_guide_char,
1452            colorizer: self.colorizer,
1453            colorizer_filetypes: self.colorizer_filetypes.clone(),
1454            format_on_save: self.format_on_save,
1455            trim_trailing_whitespace: self.trim_trailing_whitespace,
1456            rainbow_brackets: self.rainbow_brackets,
1457            updatetime: self.updatetime,
1458            matchparen: self.matchparen,
1459            fixendofline: self.fixendofline,
1460        }
1461    }
1462
1463    /// Apply a SPEC [`crate::types::Options`] overlay onto these settings.
1464    /// Pure [`Settings`] surface — see [`Settings::to_options`].
1465    /// [`Editor::apply_options`] delegates here.
1466    ///
1467    /// Writes every `Options` field that has a `Settings` counterpart, so it
1468    /// is the exact inverse of [`Settings::to_options`]. `Settings`-only
1469    /// fields (`commentstring`, `makeprg`, `errorformat`, `autopair`,
1470    /// `autoclose_tag`, `tabline_icons`, `blame_inline`,
1471    /// `diagnostics_inline`, `scroll_duration_ms`, `selection_exclusive`,
1472    /// `undo_granularity`) are host-set and deliberately left untouched.
1473    pub fn apply_options(&mut self, opts: &crate::types::Options) {
1474        self.shiftwidth = opts.shiftwidth as usize;
1475        self.tabstop = opts.tabstop as usize;
1476        self.softtabstop = opts.softtabstop as usize;
1477        self.textwidth = opts.textwidth as usize;
1478        self.expandtab = opts.expandtab;
1479        self.ignore_case = opts.ignorecase;
1480        self.smartcase = opts.smartcase;
1481        self.hlsearch = opts.hlsearch;
1482        self.incsearch = opts.incsearch;
1483        self.modeline = opts.modeline;
1484        self.modelines = opts.modelines;
1485        self.wrapscan = opts.wrapscan;
1486        self.wrap = wrap_from_mode(opts.wrap);
1487        self.readonly = opts.readonly;
1488        self.modifiable = opts.modifiable;
1489        self.autoindent = opts.autoindent;
1490        self.smartindent = opts.smartindent;
1491        self.undo_levels = opts.undo_levels;
1492        self.undo_break_on_motion = opts.undo_break_on_motion;
1493        self.iskeyword.clone_from(&opts.iskeyword);
1494        self.timeout_len = opts.timeout_len;
1495        self.number = opts.number;
1496        self.relativenumber = opts.relativenumber;
1497        self.numberwidth = opts.numberwidth;
1498        self.cursorline = opts.cursorline;
1499        self.cursorcolumn = opts.cursorcolumn;
1500        self.signcolumn = opts.signcolumn;
1501        self.foldcolumn = opts.foldcolumn;
1502        self.foldmethod = opts.foldmethod;
1503        self.foldenable = opts.foldenable;
1504        self.foldlevelstart = opts.foldlevelstart;
1505        self.foldmarker.clone_from(&opts.foldmarker);
1506        self.colorcolumn.clone_from(&opts.colorcolumn);
1507        self.formatoptions.clone_from(&opts.formatoptions);
1508        self.filetype.clone_from(&opts.filetype);
1509        self.scrolloff = opts.scrolloff;
1510        self.sidescrolloff = opts.sidescrolloff;
1511        self.autoreload = opts.autoreload;
1512        self.motion_sneak = opts.motion_sneak;
1513        self.list = opts.list;
1514        self.listchars = opts.listchars.clone();
1515        self.indent_guides = opts.indent_guides;
1516        self.indent_guide_char = opts.indent_guide_char;
1517        self.colorizer = opts.colorizer;
1518        self.colorizer_filetypes
1519            .clone_from(&opts.colorizer_filetypes);
1520        self.format_on_save = opts.format_on_save;
1521        self.trim_trailing_whitespace = opts.trim_trailing_whitespace;
1522        self.rainbow_brackets = opts.rainbow_brackets;
1523        self.updatetime = opts.updatetime;
1524        self.matchparen = opts.matchparen;
1525        self.fixendofline = opts.fixendofline;
1526    }
1527}
1528
1529/// Translate a SPEC [`crate::types::Options`] into the engine's
1530/// internal [`Settings`] representation. Field-by-field map; the
1531/// shapes are isomorphic except for type widths
1532/// (`u32` vs `usize`, [`crate::types::WrapMode`] vs
1533/// [`hjkl_buffer::Wrap`]). 0.1.0 (Patch C-δ) collapses both into one
1534/// type once the `Editor<B, H>::new(buffer, host, options)` constructor
1535/// is the canonical entry point.
1536fn settings_from_options(o: &crate::types::Options) -> Settings {
1537    Settings {
1538        shiftwidth: o.shiftwidth as usize,
1539        tabstop: o.tabstop as usize,
1540        softtabstop: o.softtabstop as usize,
1541        ignore_case: o.ignorecase,
1542        smartcase: o.smartcase,
1543        hlsearch: o.hlsearch,
1544        incsearch: o.incsearch,
1545        modeline: o.modeline,
1546        modelines: o.modelines,
1547        wrapscan: o.wrapscan,
1548        textwidth: o.textwidth as usize,
1549        expandtab: o.expandtab,
1550        wrap: wrap_from_mode(o.wrap),
1551        readonly: o.readonly,
1552        modifiable: o.modifiable,
1553        autoindent: o.autoindent,
1554        smartindent: o.smartindent,
1555        undo_levels: o.undo_levels,
1556        undo_break_on_motion: o.undo_break_on_motion,
1557        iskeyword: o.iskeyword.clone(),
1558        timeout_len: o.timeout_len,
1559        number: o.number,
1560        relativenumber: o.relativenumber,
1561        numberwidth: o.numberwidth,
1562        cursorline: o.cursorline,
1563        cursorcolumn: o.cursorcolumn,
1564        signcolumn: o.signcolumn,
1565        foldcolumn: o.foldcolumn,
1566        foldmethod: o.foldmethod,
1567        foldenable: o.foldenable,
1568        foldlevelstart: o.foldlevelstart,
1569        foldmarker: o.foldmarker.clone(),
1570        colorcolumn: o.colorcolumn.clone(),
1571        formatoptions: o.formatoptions.clone(),
1572        filetype: o.filetype.clone(),
1573        commentstring: String::new(),
1574        makeprg: "cargo check".to_string(),
1575        errorformat: "%f:%l:%c:%m,%f:%l:%m,%l:%c:%m".to_string(),
1576        autopair: true,
1577        autoclose_tag: true,
1578        scrolloff: o.scrolloff,
1579        sidescrolloff: o.sidescrolloff,
1580        autoreload: o.autoreload,
1581        motion_sneak: o.motion_sneak,
1582        list: o.list,
1583        tabline_icons: true,
1584        blame_inline: true,
1585        diagnostics_inline: crate::types::DiagInlineMode::All,
1586        listchars: o.listchars.clone(),
1587        indent_guides: o.indent_guides,
1588        indent_guide_char: o.indent_guide_char,
1589        colorizer: o.colorizer,
1590        colorizer_filetypes: o.colorizer_filetypes.clone(),
1591        format_on_save: o.format_on_save,
1592        trim_trailing_whitespace: o.trim_trailing_whitespace,
1593        rainbow_brackets: o.rainbow_brackets,
1594        updatetime: o.updatetime,
1595        matchparen: o.matchparen,
1596        fixendofline: o.fixendofline,
1597        scroll_duration_ms: 0,
1598        // `selection_exclusive` is not part of `Options` — it is set
1599        // programmatically by the host (e.g. VSCode keybinding mode via
1600        // `propagate_vscode_settings`). Default to `false` (vim inclusive).
1601        selection_exclusive: false,
1602        // `undo_granularity` is not part of `Options` — set programmatically
1603        // by the host. Default: `InsertSession` (vim parity).
1604        undo_granularity: UndoGranularity::InsertSession,
1605    }
1606}
1607
1608/// Host-observable LSP requests triggered by editor bindings. The
1609/// hjkl-engine crate doesn't talk to an LSP itself — it just raises an
1610/// intent that the TUI layer picks up and routes to `sqls`.
1611#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1612pub enum LspIntent {
1613    /// `gd` — textDocument/definition at the cursor.
1614    GotoDefinition,
1615}
1616
1617impl<H: crate::types::Host> Editor<hjkl_buffer::View, H> {
1618    /// Build an [`Editor`] from a buffer, host adapter, and SPEC options.
1619    ///
1620    /// 0.1.0 (Patch C-δ): canonical, frozen constructor per SPEC §"Editor
1621    /// surface". Replaces the pre-0.1.0 `Editor::new(KeybindingMode)` /
1622    /// `with_host` / `with_options` triad — there is no shim.
1623    ///
1624    /// Consumers that don't need a custom host pass
1625    /// [`crate::types::DefaultHost::new()`]; consumers that don't need
1626    /// custom options pass [`crate::types::Options::default()`].
1627    pub fn new(buffer: hjkl_buffer::View, host: H, options: crate::types::Options) -> Self {
1628        let settings = settings_from_options(&options);
1629        Self {
1630            // No discipline: the engine cannot name one. Callers that want vim
1631            // keys build through `hjkl_vim::vim_editor` (or call
1632            // `hjkl_vim::install_vim_discipline`), which fills this slot.
1633            discipline: Box::new(crate::NoDiscipline),
1634            extra_selections: Vec::new(),
1635            view: crate::ViewMode::default(),
1636            change_bank: std::sync::Arc::new(std::sync::Mutex::new(ChangeBank::default())),
1637            viewport_height: AtomicU16::new(0),
1638            pending_lsp: None,
1639            suppress_u_line_track: false,
1640            buffer,
1641            style_table: Vec::new(),
1642            registers: std::sync::Arc::new(std::sync::Mutex::new(
1643                crate::registers::Registers::default(),
1644            )),
1645            styled_spans: Vec::new(),
1646            settings,
1647            global_marks: std::sync::Arc::new(std::sync::Mutex::new(
1648                std::collections::BTreeMap::new(),
1649            )),
1650            jump_back: Vec::new(),
1651            jump_fwd: Vec::new(),
1652            viewport_pinned: false,
1653            scroll_anim_hint: false,
1654            search_prompt: None,
1655            search: std::sync::Arc::new(std::sync::Mutex::new(SearchBank::default())),
1656            last_input_at: None,
1657            last_input_host_at: None,
1658            last_substitute: std::sync::Arc::new(std::sync::Mutex::new(None)),
1659            pending_closes: Vec::new(),
1660            abbrevs: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
1661            yank_linewise: false,
1662            current_buffer_id: 0,
1663            sticky_col: None,
1664            host,
1665            last_emitted_mode: crate::CoarseMode::Normal,
1666            search_state: crate::search::SearchState::new(),
1667            buffer_spans: Vec::new(),
1668            last_indent_range: None,
1669        }
1670    }
1671}
1672
1673impl<B: crate::types::View, H: crate::types::Host> Editor<B, H> {
1674    /// Borrow the buffer (typed `&B`). Host renders through this via
1675    /// `hjkl_buffer::BufferView` when `B = hjkl_buffer::View`.
1676    pub fn buffer(&self) -> &B {
1677        &self.buffer
1678    }
1679
1680    /// Mutably borrow the buffer (typed `&mut B`).
1681    pub fn buffer_mut(&mut self) -> &mut B {
1682        &mut self.buffer
1683    }
1684
1685    /// Borrow the host adapter directly (typed `&H`).
1686    pub fn host(&self) -> &H {
1687        &self.host
1688    }
1689
1690    /// Mutably borrow the host adapter (typed `&mut H`).
1691    pub fn host_mut(&mut self) -> &mut H {
1692        &mut self.host
1693    }
1694}
1695
1696impl<H: crate::types::Host> Editor<hjkl_buffer::View, H> {
1697    /// Update the active `iskeyword` spec for word motions
1698    /// (`w`/`b`/`e`/`ge` and engine-side `*`/`#` pickup). 0.0.28
1699    /// hoisted iskeyword storage out of `View` — `Editor` is the
1700    /// single owner now. Equivalent to assigning
1701    /// `settings_mut().iskeyword` directly; the dedicated setter is
1702    /// retained for source-compatibility with 0.0.27 callers.
1703    pub fn set_iskeyword(&mut self, spec: impl Into<String>) {
1704        self.settings.iskeyword = spec.into();
1705    }
1706
1707    /// Emit `Host::emit_cursor_shape` if the public mode has changed
1708    /// since the last emit. Engine calls this at the end of every input
1709    /// step so mode transitions surface to the host without sprinkling
1710    /// the call across every `vim.mode = ...` site.
1711    pub fn emit_cursor_shape_if_changed(&mut self) {
1712        // Coarse, not vim: the engine emits render chrome for whatever
1713        // discipline is installed (#265).
1714        let mode = self.coarse_mode();
1715        if mode == self.last_emitted_mode {
1716            return;
1717        }
1718        let exclusive = self.settings.selection_exclusive;
1719        let shape = match mode {
1720            crate::CoarseMode::Insert => crate::types::CursorShape::Bar,
1721            // VSCode: exclusive-visual also uses a bar caret (caret between chars).
1722            crate::CoarseMode::Select if exclusive => crate::types::CursorShape::Bar,
1723            _ => crate::types::CursorShape::Block,
1724        };
1725        self.host.emit_cursor_shape(shape);
1726        self.last_emitted_mode = mode;
1727    }
1728
1729    /// Record a yank/cut payload. Forwards the text to
1730    /// [`crate::types::Host::write_clipboard`] so the platform-clipboard
1731    /// integration can store or transmit it.
1732    pub fn record_yank_to_host(&mut self, text: String) {
1733        self.host.write_clipboard(text);
1734    }
1735
1736    /// Vim's sticky column (curswant). `None` before the first motion;
1737    /// hosts shouldn't normally need to read this directly — it's
1738    /// surfaced for migration off `View::sticky_col` and for
1739    /// snapshot tests.
1740    pub fn sticky_col(&self) -> Option<usize> {
1741        self.sticky_col
1742    }
1743
1744    /// Replace the sticky column. Hosts should rarely touch this —
1745    /// motion code maintains it through the standard horizontal /
1746    /// vertical motion paths.
1747    pub fn set_sticky_col(&mut self, col: Option<usize>) {
1748        self.sticky_col = col;
1749    }
1750
1751    /// Host hook: replace the cached syntax-derived block ranges that
1752    /// `:foldsyntax` consumes. the host calls this on every re-parse;
1753    /// the cost is just a `Vec` swap.
1754    /// Look up a named mark by character. Returns `(row, col)` if
1755    /// set; `None` otherwise. Both lowercase (`'a`–`'z`) and
1756    /// uppercase (`'A`–`'Z`) marks live in the same unified
1757    /// [`Editor::marks`] map as of 0.0.36.
1758    pub fn mark(&self, c: char) -> Option<(usize, usize)> {
1759        self.buffer.mark(c)
1760    }
1761
1762    /// Set the named mark `c` to `(row, col)`. Used by the FSM's
1763    /// `m{a-zA-Z}` keystroke and by [`Editor::restore_snapshot`].
1764    pub fn set_mark(&mut self, c: char, pos: (usize, usize)) {
1765        self.buffer.set_mark(c, pos);
1766    }
1767
1768    /// Remove the named mark `c` (no-op if unset).
1769    pub fn clear_mark(&mut self, c: char) {
1770        self.buffer.clear_mark(c);
1771    }
1772
1773    /// Look up an uppercase global mark by letter. Returns
1774    /// `(buffer_id, row, col)` if set; `None` otherwise.
1775    pub fn global_mark(&self, c: char) -> Option<(u64, usize, usize)> {
1776        self.global_marks.lock().unwrap().get(&c).copied()
1777    }
1778
1779    /// Set an uppercase global mark `c` to `(buffer_id, row, col)`.
1780    pub fn set_global_mark(&mut self, c: char, buffer_id: u64, pos: (usize, usize)) {
1781        self.global_marks
1782            .lock()
1783            .unwrap()
1784            .insert(c, (buffer_id, pos.0, pos.1));
1785    }
1786
1787    /// Point this editor at a shared global-marks bank. All editors in the
1788    /// app share one bank (mirrors [`Editor::set_registers_arc`]) so
1789    /// uppercase marks set in one window/split are visible from every other
1790    /// window — vim's `mA`/`'A` are session-global, not per-window.
1791    pub fn set_global_marks_arc(
1792        &mut self,
1793        global_marks: std::sync::Arc<std::sync::Mutex<GlobalMarks>>,
1794    ) {
1795        self.global_marks = global_marks;
1796    }
1797
1798    /// Return the `buffer_id` this editor is currently attached to.
1799    pub fn current_buffer_id(&self) -> u64 {
1800        self.current_buffer_id
1801    }
1802
1803    /// Update the `buffer_id` this editor is attached to. Called by the
1804    /// app on every `switch_to` so global-mark sets record the correct id.
1805    pub fn set_current_buffer_id(&mut self, id: u64) {
1806        self.current_buffer_id = id;
1807    }
1808
1809    /// Iterate all global marks (`'A'`–`'Z'`), yielding
1810    /// `(mark_char, buffer_id, row, col)`.
1811    pub fn global_marks_iter(&self) -> Vec<(char, u64, usize, usize)> {
1812        self.global_marks
1813            .lock()
1814            .unwrap()
1815            .iter()
1816            .map(|(c, &(bid, r, col))| (*c, bid, r, col))
1817            .collect()
1818    }
1819
1820    /// Discard the most recent undo entry. Used by ex commands that
1821    /// pre-emptively pushed an undo state (`:s`, `:r`) but ended up
1822    /// matching nothing — popping prevents a no-op undo step from
1823    /// polluting the user's history.
1824    ///
1825    /// Returns `true` if an entry was discarded.
1826    pub fn pop_last_undo(&mut self) -> bool {
1827        self.buffer.pop_committed()
1828    }
1829
1830    /// Read all named marks set this session — both lowercase
1831    /// (`'a`–`'z`) and uppercase (`'A`–`'Z`). Iteration is
1832    /// deterministic (BTreeMap-ordered) so snapshot / `:marks`
1833    /// output is stable.
1834    pub fn marks(&self) -> impl Iterator<Item = (char, (usize, usize))> {
1835        self.buffer.marks_cloned().into_iter()
1836    }
1837
1838    /// Position of the last edit (where `.` would replay). `None` if
1839    /// no edit has happened yet on this buffer. Per-buffer (audit B3) —
1840    /// reads the shared [`ChangeBank`] so a `` `. `` jump in one split sees
1841    /// an edit made in a sibling split on the same buffer.
1842    pub fn last_edit_pos(&self) -> Option<(usize, usize)> {
1843        self.change_bank.lock().unwrap().last_edit
1844    }
1845
1846    /// Read-only view of the file-marks table — uppercase / "file"
1847    /// marks (`'A`–`'Z`) the host has set this session. Returns an
1848    /// iterator of `(mark_char, (row, col))` pairs.
1849    ///
1850    /// Mutate via the FSM (`m{A-Z}` keystroke) or via
1851    /// [`Editor::restore_snapshot`].
1852    ///
1853    /// 0.0.36: file marks now live in the unified [`Editor::marks`]
1854    /// map; this accessor is kept for source compatibility and
1855    /// filters the unified map to uppercase entries.
1856    pub fn file_marks(&self) -> impl Iterator<Item = (char, (usize, usize))> {
1857        self.buffer
1858            .marks_cloned()
1859            .into_iter()
1860            .filter(|(c, _)| c.is_ascii_uppercase())
1861    }
1862
1863    /// Read-only view of the cached syntax-derived block ranges that
1864    /// `:foldsyntax` consumes. Returns the slice the host last
1865    /// installed via [`Editor::set_syntax_fold_ranges`]; empty when
1866    /// no syntax integration is active.
1867    pub fn syntax_fold_ranges(&self) -> Vec<(usize, usize)> {
1868        self.buffer.syntax_fold_ranges_cloned()
1869    }
1870
1871    pub fn set_syntax_fold_ranges(&mut self, ranges: Vec<(usize, usize)>) {
1872        self.buffer.set_syntax_fold_ranges(ranges);
1873    }
1874
1875    /// Live settings (read-only). `:set` mutates these via
1876    /// [`Editor::settings_mut`].
1877    pub fn settings(&self) -> &Settings {
1878        &self.settings
1879    }
1880
1881    /// Live settings (mutable). `:set` flows through here to mutate
1882    /// shiftwidth / tabstop / textwidth / ignore_case / wrap. Hosts
1883    /// configuring at startup typically construct a [`Settings`]
1884    /// snapshot and overwrite via `*editor.settings_mut() = …`.
1885    pub fn settings_mut(&mut self) -> &mut Settings {
1886        &mut self.settings
1887    }
1888
1889    /// Set the active filetype (language name) for the current buffer.
1890    /// Used by comment-continuation and future language-aware features.
1891    /// Equivalent to `:set filetype=<lang>`. Pass `""` to clear.
1892    pub fn set_filetype(&mut self, lang: &str) {
1893        self.settings.filetype = lang.to_string();
1894    }
1895
1896    /// Returns `true` when `:set readonly` is active. Convenience
1897    /// accessor for hosts that cannot import the internal [`Settings`]
1898    /// type. Phase 5 binary uses this to gate `:w` writes.
1899    pub fn is_readonly(&self) -> bool {
1900        self.settings.readonly
1901    }
1902
1903    /// Returns `true` when the buffer is modifiable (default). When `false`
1904    /// (`:set nomodifiable`), ALL edits and insert-mode entry are blocked.
1905    pub fn is_modifiable(&self) -> bool {
1906        self.settings.modifiable
1907    }
1908
1909    /// Borrow the engine search state. Hosts inspecting the
1910    /// committed `/` / `?` pattern (e.g. for status-line display) or
1911    /// feeding the active regex into `BufferView::search_pattern`
1912    /// read it from here.
1913    pub fn search_state(&self) -> &crate::search::SearchState {
1914        &self.search_state
1915    }
1916
1917    /// Mutable engine search state. Hosts driving search
1918    /// programmatically (test fixtures, scripted demos) write the
1919    /// pattern through here.
1920    pub fn search_state_mut(&mut self) -> &mut crate::search::SearchState {
1921        &mut self.search_state
1922    }
1923
1924    /// Install `pattern` as the active search regex on the engine
1925    /// state and clear the cached row matches. Pass `None` to clear.
1926    /// 0.0.37: dropped the buffer-side mirror that 0.0.35 introduced
1927    /// — `BufferView` now takes the regex through its `search_pattern`
1928    /// field per step 3 of `DESIGN_33_METHOD_CLASSIFICATION.md`.
1929    pub fn set_search_pattern(&mut self, pattern: Option<regex::Regex>) {
1930        self.search_state.set_pattern(pattern);
1931    }
1932
1933    /// Drive `n` (or the `/` commit equivalent) — advance the cursor
1934    /// to the next match of `search_state.pattern` from the cursor's
1935    /// current position. Returns `true` when a match was found.
1936    /// `skip_current = true` excludes a match the cursor sits on.
1937    /// Opens any fold hiding the match row (vim-correct: search reveals folds).
1938    pub fn search_advance_forward(&mut self, skip_current: bool) -> bool {
1939        let found =
1940            crate::search::search_forward(&mut self.buffer, &mut self.search_state, skip_current);
1941        if found {
1942            let row = crate::types::Cursor::cursor(&self.buffer).line as usize;
1943            self.buffer.reveal_row(row);
1944            self.sync_sticky_col_to_cursor();
1945        }
1946        found
1947    }
1948
1949    /// Drive `N` — symmetric counterpart of [`Editor::search_advance_forward`].
1950    /// Opens any fold hiding the match row (vim-correct: search reveals folds).
1951    pub fn search_advance_backward(&mut self, skip_current: bool) -> bool {
1952        let found =
1953            crate::search::search_backward(&mut self.buffer, &mut self.search_state, skip_current);
1954        if found {
1955            let row = crate::types::Cursor::cursor(&self.buffer).line as usize;
1956            self.buffer.reveal_row(row);
1957            self.sync_sticky_col_to_cursor();
1958        }
1959        found
1960    }
1961
1962    /// Reset `sticky_col` (vim's `curswant`) to the column the cursor is
1963    /// currently on.
1964    ///
1965    /// A search hit is an explicit jump, and vim resets `curswant` on every
1966    /// explicit jump — so the next `j`/`k` aims at the match's column rather
1967    /// than at wherever the cursor sat before the search.
1968    ///
1969    /// This lives on the *advance* rather than at each call site because
1970    /// there are four of them across two crates (`App::commit_search`, the
1971    /// `+/pattern` startup search, the vim search prompt, and the `n`/`N`
1972    /// motion) and only the last happened to be correct — it runs through the
1973    /// vim motion dispatch, which ends in `apply_sticky_col`. Putting it here
1974    /// makes the guarantee structural instead of remembered; the motion path
1975    /// then re-sets the same value, which is a no-op.
1976    fn sync_sticky_col_to_cursor(&mut self) {
1977        let pos = buf_cursor_pos(&self.buffer);
1978        let line = buf_line(&self.buffer, pos.row).unwrap_or_default();
1979        self.sticky_col = Some(char_col_to_visual_col(
1980            &line,
1981            pos.col,
1982            self.settings().tabstop,
1983        ));
1984    }
1985
1986    /// Snapshot of the unnamed register (the default `p` / `P` source).
1987    pub fn yank(&self) -> String {
1988        self.registers.lock().unwrap().unnamed.text.clone()
1989    }
1990
1991    /// Run `f` with shared read access to the register bank — `"`,
1992    /// `"0`–`"9`, `"a`–`"z`. The lock is scoped to the closure — the guard
1993    /// can never escape into caller code, so it can't be held across
1994    /// unrelated editor calls (re-entrancy/deadlock footgun). Never call
1995    /// back into other `ed.` methods that might lock the register bank
1996    /// from inside `f` — extract owned data first if you need to.
1997    pub fn with_registers<R>(&self, f: impl FnOnce(&crate::registers::Registers) -> R) -> R {
1998        f(&self.registers.lock().unwrap())
1999    }
2000
2001    /// Mutable counterpart of [`Editor::with_registers`]. Same
2002    /// closure-scoping invariant: never re-enter the editor from inside
2003    /// `f`, or the mutex will deadlock.
2004    pub fn with_registers_mut<R>(
2005        &self,
2006        f: impl FnOnce(&mut crate::registers::Registers) -> R,
2007    ) -> R {
2008        f(&mut self.registers.lock().unwrap())
2009    }
2010
2011    /// Point this editor at a shared register bank. All editors in the
2012    /// app share one bank so yank/paste work cross-buffer without copying.
2013    pub fn set_registers_arc(
2014        &mut self,
2015        registers: std::sync::Arc<std::sync::Mutex<crate::registers::Registers>>,
2016    ) {
2017        self.registers = registers;
2018    }
2019
2020    /// Host hook: load the OS clipboard's contents into the `"+` / `"*`
2021    /// register slot. the host calls this before letting vim consume a
2022    /// paste so `"*p` / `"+p` reflect the live clipboard rather than a
2023    /// stale snapshot from the last yank.
2024    pub fn sync_clipboard_register(&mut self, text: String, linewise: bool) {
2025        self.registers.lock().unwrap().set_clipboard(text, linewise);
2026    }
2027
2028    /// Snapshot of the change list (positions of recent edits) plus the
2029    /// current walk cursor. Newest entry is at the back. Per-buffer (audit
2030    /// B3) — reads the shared [`ChangeBank`], so this reflects edits made
2031    /// from any window/split on the same buffer.
2032    ///
2033    /// Returns owned data rather than a borrow: the bank lives behind a
2034    /// `Mutex`, so a borrow can't outlive the guard (mirrors
2035    /// [`Editor::global_marks_iter`] / [`Editor::abbrevs`]).
2036    pub fn change_list(&self) -> (Vec<(usize, usize)>, Option<usize>) {
2037        let bank = self.change_bank.lock().unwrap();
2038        (bank.list.clone(), bank.cursor)
2039    }
2040
2041    /// Replace the unnamed register without touching any other slot.
2042    /// For host-driven imports (e.g. system clipboard); operator
2043    /// code uses [`record_yank`] / [`record_delete`].
2044    pub fn set_yank(&mut self, text: impl Into<String>) {
2045        let text = text.into();
2046        let linewise = self.yank_linewise;
2047        self.registers.lock().unwrap().unnamed = crate::registers::Slot {
2048            text,
2049            linewise,
2050            ..Default::default()
2051        };
2052    }
2053
2054    /// Record a yank into `"` and `"0`, plus the named target if the
2055    /// user prefixed `"reg`. Updates `vim.yank_linewise` for the
2056    /// paste path.
2057    pub fn record_yank(&mut self, text: String, linewise: bool, target: Option<char>) {
2058        self.yank_linewise = linewise;
2059        self.registers
2060            .lock()
2061            .unwrap()
2062            .record_yank(text, linewise, target);
2063    }
2064
2065    /// Record a blockwise (visual-block) yank. `width` is the block's
2066    /// column width — every row segment pads to it (with trailing spaces)
2067    /// on paste. `text` is the row segments joined with `\n` (kept as-is
2068    /// for charwise-fallback / RPC). Clears the cached linewise flag.
2069    pub fn record_yank_block(&mut self, text: String, width: usize, target: Option<char>) {
2070        self.yank_linewise = false;
2071        self.registers
2072            .lock()
2073            .unwrap()
2074            .record_yank_block(text, width, target);
2075    }
2076
2077    /// Direct write to a named OR numbered register slot — bypasses the
2078    /// unnamed `"` and `"0` updates that `record_yank` does. Used by the
2079    /// macro recorder so finishing a `q{reg}` recording doesn't pollute
2080    /// the user's last yank.
2081    ///
2082    /// vim's `q{0-9a-zA-Z"}` accepts digit targets too (`:h q`) — `q1`
2083    /// records into `"1`, shadowing whatever the delete/change ring had
2084    /// there, exactly like `qa` overwrites `"a` (audit-r2 fix 5). Digits
2085    /// route to the SAME slots `Registers::read` resolves `'0'`-`'9'`
2086    /// against (`yank_zero` / `delete_ring`), so `@1` replays what `q1`
2087    /// recorded.
2088    pub fn set_named_register_text(&mut self, reg: char, text: String) {
2089        let mut regs = self.registers.lock().unwrap();
2090        if let Some(slot) = match reg {
2091            'a'..='z' => Some(&mut regs.named[(reg as u8 - b'a') as usize]),
2092            'A'..='Z' => Some(&mut regs.named[(reg.to_ascii_lowercase() as u8 - b'a') as usize]),
2093            '0' => Some(&mut regs.yank_zero),
2094            '1'..='9' => Some(&mut regs.delete_ring[(reg as u8 - b'1') as usize]),
2095            _ => None,
2096        } {
2097            slot.text = text;
2098            slot.linewise = false;
2099        }
2100    }
2101
2102    /// Record a delete / change into `"` and, by size, the `"1`–`"9`
2103    /// ring or the `"-` small-delete register. Honours the active
2104    /// named-register prefix.
2105    pub fn record_delete(&mut self, text: String, linewise: bool, target: Option<char>) {
2106        self.yank_linewise = linewise;
2107        self.registers
2108            .lock()
2109            .unwrap()
2110            .record_delete(text, linewise, target);
2111    }
2112
2113    /// Record a blockwise (visual-block) delete / change. See
2114    /// [`Editor::record_yank_block`] for the `width` / `text` contract.
2115    pub fn record_delete_block(&mut self, text: String, width: usize, target: Option<char>) {
2116        self.yank_linewise = false;
2117        self.registers
2118            .lock()
2119            .unwrap()
2120            .record_delete_block(text, width, target);
2121    }
2122
2123    /// Install styled syntax spans using the engine-native
2124    /// [`crate::types::Style`]. Always available — engine is ratatui-free.
2125    /// Ratatui hosts use
2126    /// `hjkl_engine_tui::EditorRatatuiExt::install_ratatui_syntax_spans`
2127    /// which converts at the boundary and delegates here.
2128    ///
2129    /// Renamed from `install_engine_syntax_spans` in 0.0.32 — at the
2130    /// 0.1.0 freeze the unprefixed name is the universally-available
2131    /// engine-native variant.
2132    ///
2133    /// `spans` is any iterator of per-row iterators, so renderer adapters can
2134    /// hand over a lazily converted view of their own span table instead of
2135    /// materialising a whole engine-typed copy first.
2136    pub fn install_syntax_spans<I, R>(&mut self, spans: I)
2137    where
2138        I: IntoIterator<Item = R>,
2139        R: IntoIterator<Item = (usize, usize, crate::types::Style)>,
2140    {
2141        let rows = spans.into_iter();
2142        let cap = rows.size_hint().0;
2143        let mut by_row: Vec<Vec<hjkl_buffer::Span>> = Vec::with_capacity(cap);
2144        let mut engine_spans: Vec<Vec<(usize, usize, crate::types::Style)>> =
2145            Vec::with_capacity(cap);
2146        for (row, row_spans) in rows.enumerate() {
2147            let (translated, translated_e) = self.translate_row_spans(row, row_spans);
2148            by_row.push(translated);
2149            engine_spans.push(translated_e);
2150        }
2151        self.buffer_spans = by_row;
2152        self.styled_spans = engine_spans;
2153    }
2154
2155    /// Clamp + intern one row's spans into the `(buffer_spans, styled_spans)`
2156    /// pair stored for that row. Shared by
2157    /// [`Self::install_syntax_spans`] and [`Self::patch_syntax_spans_range`].
2158    ///
2159    /// Note: do NOT pre-collect line lengths for the whole buffer. Every
2160    /// row-length lookup takes the content mutex; pre-collecting for a
2161    /// 10k-row file turns one install into 10k locks (visible as j/k cursor
2162    /// lag). The lookup here is lazy — a row with no spans never touches the
2163    /// buffer — and memoized per row, so the cost stays proportional to
2164    /// populated rows, not file size.
2165    ///
2166    /// The length comes from [`buf_line_bytes`] (→ `Query::line_bytes`,
2167    /// overridden for `hjkl_buffer::View` to read the rope's line length
2168    /// under one lock with no allocation), *not* from `buf_line(row).len()`,
2169    /// which cloned the whole row into a `String` just to read its length.
2170    /// Both are BYTE counts — `Span::{start,end}_byte` and the tree-sitter
2171    /// ranges they come from are byte offsets, so the clamp unit is
2172    /// unchanged. See `line_bytes_matches_line_len` in `buffer_impl` for the
2173    /// pinned equivalence (and the one documented divergence: rows ended by
2174    /// a non-LF Unicode line break, where `line_bytes` excludes the
2175    /// terminator byte the `String` form kept — the clamp can only get
2176    /// tighter, never looser).
2177    fn translate_row_spans<R>(
2178        &mut self,
2179        row: usize,
2180        row_spans: R,
2181    ) -> (
2182        Vec<hjkl_buffer::Span>,
2183        Vec<(usize, usize, crate::types::Style)>,
2184    )
2185    where
2186        R: IntoIterator<Item = (usize, usize, crate::types::Style)>,
2187    {
2188        let it = row_spans.into_iter();
2189        let cap = it.size_hint().0;
2190        let mut translated = Vec::with_capacity(cap);
2191        let mut translated_e = Vec::with_capacity(cap);
2192        let mut line_len: Option<usize> = None;
2193        for (start, end, style) in it {
2194            let len = match line_len {
2195                Some(l) => l,
2196                None => {
2197                    let l = buf_line_bytes(&self.buffer, row);
2198                    line_len = Some(l);
2199                    l
2200                }
2201            };
2202            let end_clamped = end.min(len);
2203            if end_clamped <= start {
2204                continue;
2205            }
2206            let id = self.intern_style(style);
2207            translated.push(hjkl_buffer::Span::new(start, end_clamped, id));
2208            translated_e.push((start, end_clamped, style));
2209        }
2210        (translated, translated_e)
2211    }
2212
2213    /// Patch only `rows` of the installed `buffer_spans` / `styled_spans`,
2214    /// leaving rows outside that range untouched. `spans` is indexed by
2215    /// row offset within `rows` — `spans[0]` is for `rows.start`,
2216    /// `spans[1]` for `rows.start + 1`, etc.
2217    ///
2218    /// Use this instead of [`Self::install_syntax_spans`] when a sync
2219    /// `query_viewport` produced spans for the visible region only.
2220    /// Walking the full `line_count` and re-installing every row on
2221    /// every j/k that nudges the viewport dominated the per-keystroke
2222    /// cost on large files; patching just the changed range keeps the
2223    /// cost proportional to viewport size, not file size.
2224    ///
2225    /// Ensures `buffer_spans` / `styled_spans` are sized to the buffer's
2226    /// current `line_count` (resizes if a row-count edit shifted them).
2227    ///
2228    /// `spans` is any iterator of per-row iterators, so renderer adapters can
2229    /// hand over a lazily converted view of their own span table instead of
2230    /// materialising a whole engine-typed copy first.
2231    pub fn patch_syntax_spans_range<I, R>(&mut self, rows: std::ops::Range<usize>, spans: I)
2232    where
2233        I: IntoIterator<Item = R>,
2234        R: IntoIterator<Item = (usize, usize, crate::types::Style)>,
2235    {
2236        let line_count = buf_row_count(&self.buffer);
2237        if self.buffer_spans.len() != line_count {
2238            self.buffer_spans.resize_with(line_count, Vec::new);
2239        }
2240        if self.styled_spans.len() != line_count {
2241            self.styled_spans.resize_with(line_count, Vec::new);
2242        }
2243        for (i, row_spans) in spans.into_iter().enumerate() {
2244            let row = rows.start + i;
2245            if row >= line_count {
2246                break;
2247            }
2248            let (translated, translated_e) = self.translate_row_spans(row, row_spans);
2249            self.buffer_spans[row] = translated;
2250            self.styled_spans[row] = translated_e;
2251        }
2252    }
2253
2254    /// Translate the cached `buffer_spans` / `styled_spans` row indices
2255    /// in-place to track a batch of [`crate::types::ContentEdit`]s without
2256    /// blanking the cache.
2257    ///
2258    /// Why: spans are installed by the async syntax worker, which can lag
2259    /// the buffer by one or more frames after an edit. If the edit changes
2260    /// the row count and we keep the old span rows in place, the renderer
2261    /// paints last-frame's spans at the wrong line — visibly garbled colours.
2262    /// The historical fix was to blank `buffer_spans` whenever a row-count
2263    /// change came through, but that produces a white flash on every Enter
2264    /// or backspace-at-BOL.
2265    ///
2266    /// What this does instead: for each edit, insert empty span rows where
2267    /// the edit grew the buffer and drain rows where it shrank, so the
2268    /// surviving rows still index the right line. Spans on the edited row
2269    /// itself stay (they'll show stale colours for that one row until the
2270    /// worker delivers a fresh parse, which is invisible compared to the
2271    /// blank flash).
2272    ///
2273    /// Edits are applied in order — each edit's `(row, col)` positions are
2274    /// taken to be relative to the post-state of the prior edits in the
2275    /// batch (matching the order the engine emitted them).
2276    pub fn shift_syntax_spans_for_edits(&mut self, edits: &[crate::types::ContentEdit]) {
2277        for edit in edits {
2278            let oer = edit.old_end_position.0 as usize;
2279            let ner = edit.new_end_position.0 as usize;
2280            if ner == oer {
2281                continue;
2282            }
2283            let start_row = edit.start_position.0 as usize;
2284            let start_col = edit.start_position.1 as usize;
2285            // Insert/drain index depends on whether the edit starts at
2286            // the BEGINNING of `start_row` or somewhere INSIDE it.
2287            //   col == 0 → edit is at the very start of `start_row`; new
2288            //              rows go BEFORE row `start_row`, so the affected
2289            //              indices begin AT `start_row`.
2290            //   col > 0 → edit is inside `start_row`; new rows go AFTER
2291            //              `start_row`, so affected indices begin at
2292            //              `start_row + 1`.
2293            //
2294            // Pre-fix this always used `oer + 1` (the col-> 0 branch),
2295            // which left row `start_row`'s spans at its old index while
2296            // the file's row `start_row` was now the freshly-pasted
2297            // content — visible as wrong-row colour mappings after
2298            // `ggP` / `P` / any insert at column 0.
2299            let affected_idx = if start_col == 0 {
2300                start_row
2301            } else {
2302                start_row + 1
2303            };
2304            if ner > oer {
2305                let n = ner - oer;
2306                // O(len + n) via splice; the prior per-row `insert(idx, ...)`
2307                // loop was O(n × (len - idx)), which on a 60k-row paste at
2308                // the BOL became ~1.8 G memmove ops (87 % of paste CPU per
2309                // samply). Splice memmove-shifts once, then fills.
2310                let idx = affected_idx.min(self.buffer_spans.len());
2311                self.buffer_spans
2312                    .splice(idx..idx, std::iter::repeat_with(Vec::new).take(n));
2313                let idx_s = affected_idx.min(self.styled_spans.len());
2314                self.styled_spans
2315                    .splice(idx_s..idx_s, std::iter::repeat_with(Vec::new).take(n));
2316            } else {
2317                let n = oer - ner;
2318                let len_b = self.buffer_spans.len();
2319                let start_b = affected_idx.min(len_b);
2320                let end_b = (start_b + n).min(len_b);
2321                if end_b > start_b {
2322                    self.buffer_spans.drain(start_b..end_b);
2323                }
2324                let len_s = self.styled_spans.len();
2325                let start_s = affected_idx.min(len_s);
2326                let end_s = (start_s + n).min(len_s);
2327                if end_s > start_s {
2328                    self.styled_spans.drain(start_s..end_s);
2329                }
2330            }
2331        }
2332    }
2333
2334    /// Read-only view of the style table in engine-native form —
2335    /// id `i` → `style_table[i]`. Always available, no cfg gate.
2336    ///
2337    /// Ratatui hosts that need `ratatui::style::Style` values convert
2338    /// entries via `hjkl_engine_tui::style_to_ratatui`.
2339    pub fn style_table(&self) -> &[crate::types::Style] {
2340        &self.style_table
2341    }
2342
2343    /// Per-row syntax span overlay, one `Vec<Span>` per buffer row.
2344    /// Hosts feed this slice into [`hjkl_buffer::BufferView::spans`]
2345    /// per draw frame.
2346    ///
2347    /// 0.0.37: replaces `editor.buffer().spans()` per step 3 of
2348    /// `DESIGN_33_METHOD_CLASSIFICATION.md`. The buffer no longer
2349    /// caches spans; they live on the engine and route through the
2350    /// `Host::syntax_highlights` pipeline.
2351    pub fn buffer_spans(&self) -> &[Vec<hjkl_buffer::Span>] {
2352        &self.buffer_spans
2353    }
2354
2355    /// Intern a SPEC [`crate::types::Style`] and return its opaque id.
2356    /// Engine-native — the unified `style_table` is always engine-native.
2357    /// Linear-scan dedup — the table grows only as new tree-sitter token
2358    /// kinds appear, so it stays tiny. Ratatui callers convert at the
2359    /// boundary with `hjkl_engine_tui::style_from_ratatui` and pass the
2360    /// engine-native style here.
2361    ///
2362    /// Renamed from `intern_engine_style` in 0.0.32 — at 0.1.0 freeze
2363    /// the unprefixed name is the universally-available engine-native
2364    /// variant.
2365    pub fn intern_style(&mut self, style: crate::types::Style) -> u32 {
2366        if let Some(idx) = self.style_table.iter().position(|s| *s == style) {
2367            return idx as u32;
2368        }
2369        self.style_table.push(style);
2370        (self.style_table.len() - 1) as u32
2371    }
2372
2373    /// Look up an interned style by id and return it as a SPEC
2374    /// [`crate::types::Style`]. Returns `None` for ids past the end
2375    /// of the table.
2376    pub fn engine_style_at(&self, id: u32) -> Option<crate::types::Style> {
2377        self.style_table.get(id as usize).copied()
2378    }
2379
2380    /// Force the host viewport's top row without touching the
2381    /// cursor. Used by tests that simulate a scroll without the
2382    /// SCROLLOFF cursor adjustment that `scroll_down` / `scroll_up`
2383    /// apply.
2384    ///
2385    /// 0.0.34 (Patch C-δ.1): writes through `Host::viewport_mut`
2386    /// instead of the (now-deleted) `View::viewport_mut`.
2387    pub fn set_viewport_top(&mut self, row: usize) {
2388        let last = buf_row_count(&self.buffer).saturating_sub(1);
2389        let target = row.min(last);
2390        self.host.viewport_mut().top_row = target;
2391    }
2392
2393    /// Set the cursor to `(row, col)`, clamped to the buffer's
2394    /// content. Hosts use this for goto-line, jump-to-mark, and
2395    /// programmatic cursor placement.
2396    ///
2397    /// Resets `sticky_col` (curswant) to `col` — every explicit jump
2398    /// (goto-line, jump-to-mark, search hit, click, `]d`) follows vim
2399    /// semantics. Only `j`/`k`/`+`/`-` READ `sticky_col`; everything
2400    /// else resets it to the column where the cursor actually landed.
2401    pub fn jump_cursor(&mut self, row: usize, col: usize) {
2402        buf_set_cursor_rc(&mut self.buffer, row, col);
2403        let line = buf_line(&self.buffer, row).unwrap_or_default();
2404        self.sticky_col = Some(char_col_to_visual_col(&line, col, self.settings().tabstop));
2405    }
2406
2407    /// Set the cursor to `(row, col)` without modifying `sticky_col`.
2408    ///
2409    /// Use this for host-side state restores (viewport sync, snapshot
2410    /// replay) where the cursor was already at this position semantically
2411    /// and the host's sticky tracking should remain authoritative.
2412    ///
2413    /// For user-facing jumps (goto-line, search hit, picker `<CR>`, `]d`,
2414    /// click), use [`Editor::jump_cursor`] which DOES reset `sticky_col`
2415    /// per vim curswant semantics.
2416    pub fn set_cursor_quiet(&mut self, row: usize, col: usize) {
2417        buf_set_cursor_rc(&mut self.buffer, row, col);
2418    }
2419
2420    /// `(row, col)` cursor read sourced from the migration buffer.
2421    /// Equivalent to `self.textarea.cursor()` when the two are in
2422    /// sync — which is the steady state during Phase 7f because
2423    /// every step opens with `sync_buffer_content_from_textarea` and
2424    /// every ported motion pushes the result back. Prefer this over
2425    /// `self.textarea.cursor()` so call sites keep working unchanged
2426    /// once the textarea field is ripped.
2427    pub fn cursor(&self) -> (usize, usize) {
2428        buf_cursor_rc(&self.buffer)
2429    }
2430
2431    /// The character under the cursor, or `None` at/after end of line (or on
2432    /// an empty line). Used by callers that need vim's on-blank distinctions
2433    /// (e.g. `cw` only acts like `ce` when the cursor is on a non-blank).
2434    pub fn char_at_cursor(&self) -> Option<char> {
2435        let (row, col) = self.cursor();
2436        crate::buf_helpers::buf_line(&self.buffer, row).and_then(|l| l.chars().nth(col))
2437    }
2438
2439    /// Drain any pending LSP intent raised by the last key. Returns
2440    /// `None` when no intent is armed.
2441    pub fn take_lsp_intent(&mut self) -> Option<LspIntent> {
2442        self.pending_lsp.take()
2443    }
2444
2445    /// Drain every [`crate::types::FoldOp`] raised since the last
2446    /// call. Hosts that mirror the engine's fold storage (or that
2447    /// project folds onto a separate fold tree, LSP folding ranges,
2448    /// …) drain this each step and dispatch as their own
2449    /// [`crate::types::Host::Intent`] requires.
2450    ///
2451    /// The engine has already applied every op locally against the
2452    /// in-tree [`hjkl_buffer::View`] fold storage via
2453    /// [`crate::buffer_impl::BufferFoldProviderMut`], so hosts that
2454    /// don't track folds independently can ignore the queue
2455    /// (or simply never call this drain).
2456    ///
2457    /// Introduced in 0.0.38 (Patch C-δ.4).
2458    pub fn take_fold_ops(&mut self) -> Vec<crate::types::FoldOp> {
2459        self.buffer.take_fold_ops()
2460    }
2461
2462    /// Dispatch a [`crate::types::FoldOp`] through the canonical fold
2463    /// surface: queue it for host observation (drained by
2464    /// [`Editor::take_fold_ops`]) and apply it locally against the
2465    /// in-tree buffer fold storage via
2466    /// [`crate::buffer_impl::BufferFoldProviderMut`]. Engine call sites
2467    /// (vim FSM `z…` chords, `:fold*` Ex commands, edit-pipeline
2468    /// invalidation) route every fold mutation through this method.
2469    ///
2470    /// Introduced in 0.0.38 (Patch C-δ.4).
2471    pub fn apply_fold_op(&mut self, op: crate::types::FoldOp) {
2472        use crate::types::FoldProvider;
2473        self.buffer.push_fold_op(op);
2474        let mut provider = crate::buffer_impl::BufferFoldProviderMut::new(&mut self.buffer);
2475        provider.apply(op);
2476        // BUG 2 fix: after a close/toggle-that-closes, the cursor may sit on a
2477        // hidden row (inside the fold body). Vim snaps the cursor to the fold's
2478        // first line (start_row). Do it here so every call site — keyboard `za`/
2479        // `zc` AND the gutter-click path — converges on the same behaviour.
2480        //
2481        // audit-r2 fix 3(b): with NESTED folds (e.g. `zM` closing every fold at
2482        // once), the innermost fold's start_row can itself be hidden by an
2483        // OUTER closed fold, so a single snap can land the cursor on another
2484        // hidden row. Repeatedly snap to the start_row of whichever fold hides
2485        // the CURRENT candidate row, always picking the fold with the smallest
2486        // start_row among those that hide it (the outermost one covering it) —
2487        // each step strictly decreases the row, so this is naturally bounded
2488        // by the row count; `max_iters` is just a defensive backstop.
2489        let mut cursor_row = buf_cursor_row(&self.buffer);
2490        let mut snapped = false;
2491        // One lock, no clone, for the whole snap loop — this used to clone
2492        // the fold `Vec` once per iteration (O(folds²) clones).
2493        self.buffer.with_folds(|folds| {
2494            for _ in 0..(folds.len() + 1) {
2495                let Some(fold) = folds
2496                    .iter()
2497                    .filter(|f| f.hides(cursor_row))
2498                    .min_by_key(|f| f.start_row)
2499                else {
2500                    break;
2501                };
2502                cursor_row = fold.start_row;
2503                snapped = true;
2504            }
2505        });
2506        if snapped {
2507            buf_set_cursor_rc(&mut self.buffer, cursor_row, 0);
2508            self.sticky_col = Some(0);
2509        }
2510    }
2511
2512    /// Refresh the host viewport's height from the cached
2513    /// `viewport_height_value()`. Called from the per-step
2514    /// boilerplate; was the textarea → buffer mirror before Phase 7f
2515    /// put View in charge. 0.0.28 hoisted sticky_col out of
2516    /// `View`. 0.0.34 (Patch C-δ.1) routes the height write through
2517    /// `Host::viewport_mut`.
2518    ///
2519    /// `viewport_height_value()` is an `AtomicU16` that starts at 0 and
2520    /// is only ever written by [`Editor::set_viewport_height`], which a
2521    /// real TUI render loop calls every frame. Headless / embedded
2522    /// hosts (tests, the oracle, `--nvim-api`) never call it, so 0 here
2523    /// means "unset", not "the window is zero rows tall". Treat it as
2524    /// such and leave the host's own (already-correct) viewport height
2525    /// alone — otherwise `M`/`L` and scrolloff math on those hosts see
2526    /// a phantom zero-height window and collapse to `H`.
2527    pub fn sync_buffer_from_textarea(&mut self) {
2528        let height = self.viewport_height_value();
2529        if height != 0 {
2530            self.host.viewport_mut().height = height;
2531        }
2532    }
2533
2534    /// Was the full textarea → buffer content sync. View is the
2535    /// content authority now; this remains as a no-op so the per-step
2536    /// call sites don't have to be ripped in the same patch.
2537    pub fn sync_buffer_content_from_textarea(&mut self) {
2538        self.sync_buffer_from_textarea();
2539    }
2540
2541    /// Push a `(row, col)` onto the back-jumplist so `Ctrl-o` returns
2542    /// to it later. Used by host-driven jumps (e.g. `gd`) that move
2543    /// the cursor without going through the vim engine's motion
2544    /// machinery, where push_jump fires automatically.
2545    pub fn record_jump(&mut self, pos: (usize, usize)) {
2546        const JUMPLIST_MAX: usize = 100;
2547        self.jump_back.push(pos);
2548        if self.jump_back.len() > JUMPLIST_MAX {
2549            self.jump_back.remove(0);
2550        }
2551        self.jump_fwd.clear();
2552    }
2553
2554    /// Host apps call this each draw with the current text area height so
2555    /// scroll helpers can clamp the cursor without recomputing layout.
2556    pub fn set_viewport_height(&self, height: u16) {
2557        self.viewport_height.store(height, Ordering::Relaxed);
2558    }
2559
2560    /// Last height published by `set_viewport_height` (in rows).
2561    pub fn viewport_height_value(&self) -> u16 {
2562        self.viewport_height.load(Ordering::Relaxed)
2563    }
2564
2565    /// Apply `edit` against the buffer and return the inverse so the
2566    /// host can push it onto an undo stack. Side effects: dirty
2567    /// flag, change-list ring, mark / jump-list shifts, change_log
2568    /// append, fold invalidation around the touched rows.
2569    ///
2570    /// The primary edit funnel — both FSM operators and ex commands
2571    /// route mutations through here so the side effects fire
2572    /// uniformly.
2573    pub fn mutate_edit(&mut self, edit: hjkl_buffer::Edit) -> hjkl_buffer::Edit {
2574        // `nomodifiable` OR the BLAME view overlay short-circuits every
2575        // mutation funnel: no buffer change, no dirty flag, no undo entry,
2576        // no change-log emission. We swallow the requested `edit` and hand
2577        // back a self-inverse no-op (`InsertStr` of an empty string at the
2578        // current cursor) so callers that push the return value onto an undo
2579        // stack still get a structurally valid round trip.
2580        // Note: `readonly` no longer blocks edits here — it only gates `:w`.
2581        if !self.settings.modifiable || self.view == crate::ViewMode::Blame {
2582            let _ = edit;
2583            return hjkl_buffer::Edit::InsertStr {
2584                at: buf_cursor_pos(&self.buffer),
2585                text: String::new(),
2586            };
2587        }
2588        // Multi-cursor (#63): every edit cascades, so the secondary selections
2589        // have to be rewritten against the *pre-edit* geometry or they end up
2590        // pointing at the wrong text. This is the single edit funnel, so doing it
2591        // here covers every mutation in the engine by construction. BOTH ends move
2592        // together, and a selection the shift cannot track exactly is dropped
2593        // whole, never guessed and never half-tracked — see `selection_shift`.
2594        if !self.extra_selections.is_empty() {
2595            let edit_ref = &edit;
2596            // `JoinLines` geometry depends on how long each row was *before* the
2597            // join, so the metrics have to be read here — after `apply_buffer_edit`
2598            // they describe the wrong buffer.
2599            let rows = buf_row_count(&self.buffer);
2600            let lens: Vec<usize> = (0..rows).map(|r| buf_line_chars(&self.buffer, r)).collect();
2601            self.extra_selections.retain_mut(|s| {
2602                match crate::selection_shift::shift_sel(
2603                    *s,
2604                    edit_ref,
2605                    |r| lens.get(r).copied().unwrap_or(0),
2606                    rows,
2607                ) {
2608                    Some(shifted) => {
2609                        *s = shifted;
2610                        true
2611                    }
2612                    None => false,
2613                }
2614            });
2615        }
2616        let pre_row = buf_cursor_row(&self.buffer);
2617        let pre_rows = buf_row_count(&self.buffer);
2618        // `U` (`:h U`) bookkeeping: remember `pre_row`'s text before the
2619        // first change lands on it, so `undo_line` can restore it later.
2620        // Reset (fresh snapshot) whenever a change lands on a DIFFERENT
2621        // row than the one currently tracked. `undo_line` sets
2622        // `suppress_u_line_track` around its own restoring edits so this
2623        // generic path doesn't clobber the toggle swap it just performed.
2624        if !self.suppress_u_line_track {
2625            let mut bank = self.change_bank.lock().unwrap();
2626            let fresh = !matches!(&bank.u_line, Some((row, _)) if *row == pre_row);
2627            if fresh {
2628                bank.u_line = Some((pre_row, buf_line(&self.buffer, pre_row).unwrap_or_default()));
2629            }
2630        }
2631        // Capture the pre-edit cursor for the dot mark (`'.` / `` `. ``).
2632        // Vim's `:h '.` says "the position where the last change was made",
2633        // meaning the change-start, not the post-insert cursor. We snap it
2634        // here before `apply_buffer_edit` moves the cursor.
2635        let (pre_edit_row, pre_edit_col) = buf_cursor_rc(&self.buffer);
2636        // Map the underlying buffer edit to a SPEC EditOp for
2637        // change-log emission before consuming it. Coarse — see
2638        // change_log field doc on the struct.
2639        self.buffer.extend_change_log(edit_to_editops(&edit));
2640        // Compute ContentEdit fan-out from the pre-edit buffer state.
2641        // Done before `apply_buffer_edit` consumes `edit` so we can
2642        // inspect the operation's fields and the buffer's pre-edit row
2643        // bytes (needed for byte_of_row / col_byte conversion). Edits
2644        // are pushed onto pending_content_edits for host drain.
2645        let content_edits = content_edits_from_buffer_edit(&self.buffer, &edit);
2646        self.buffer.extend_pending_content_edits(content_edits);
2647        // 0.0.42 (Patch C-δ.7): the `apply_edit` reach is centralized
2648        // in [`crate::buf_helpers::apply_buffer_edit`] (option (c) of
2649        // the 0.0.42 plan — see that fn's doc comment). The free fn
2650        // takes `&mut hjkl_buffer::View` so the editor body itself
2651        // no longer carries a `self.buffer.<inherent>` hop.
2652        let inverse = apply_buffer_edit(&mut self.buffer, edit);
2653        let (pos_row, pos_col) = buf_cursor_rc(&self.buffer);
2654        let post_edit_pos = (pos_row, pos_col);
2655        // Row-count delta the edit produced, needed both to decide how
2656        // folds react (below) and to shift marks/jumplist (below).
2657        // Computed here, right after the buffer mutation, so both use
2658        // the same value.
2659        let post_rows = buf_row_count(&self.buffer);
2660        let delta = post_rows as isize - pre_rows as isize;
2661        if delta == 0 {
2662            // No row-count change: approximate vim's "opening the fold
2663            // you just edited inside" by dropping any fold covering
2664            // either the pre-edit or post-edit cursor row. This catches
2665            // the common single-line edit shapes but is a blunt
2666            // approximation — see `apply_fold_op`'s Invalidate doc.
2667            //
2668            // When the edit DOES change the row count, folds instead go
2669            // through `shift_marks_after_edit` -> `rebase_folds` below,
2670            // which grows/shrinks/clips/drops a fold precisely instead of
2671            // always dropping it (#audit-r2 fix 1).
2672            let lo = pre_row.min(pos_row);
2673            let hi = pre_row.max(pos_row);
2674            self.apply_fold_op(crate::types::FoldOp::Invalidate {
2675                start_row: lo,
2676                end_row: hi,
2677            });
2678        }
2679        // Dot mark / changelist record the PRE-edit position (change
2680        // start), matching vim's `:h '.` semantics — verified against real
2681        // nvim across single-/multi-char inserts, appends, `dw`, `dd`, and
2682        // `x`: `g;` / `` `. `` always land at the change's *start*, never
2683        // wherever the cursor ended up after typing. Previously `'.` used
2684        // the post-edit cursor (diverged from nvim on `iX<Esc>j`) and `g;`
2685        // used it too (diverged on any multi-char change: `AY<Esc>` at the
2686        // end of a 3-char line landed `g;` on col 4, past the Y, instead
2687        // of col 3, on it).
2688        //
2689        // A whole insert-mode session (`AXYZ<Esc>`) is vim's ONE change,
2690        // not three, even though each keystroke is its own `mutate_edit`
2691        // call — confirmed against real nvim (a second `g;` right after
2692        // `AXYZ<Esc>` errors "at start of changelist" rather than finding
2693        // a second entry). Detect burst continuation by comparing this
2694        // edit's pre-edit position against the PREVIOUS call's post-edit
2695        // position: an unbroken typing stream leaves the cursor exactly
2696        // where the next keystroke's edit begins. A `cw`-style
2697        // delete-then-insert combo also chains this way naturally (the
2698        // delete's post-edit cursor is the insert's start), so it lands
2699        // `g;` at the deletion point — matching vim's "one logical
2700        // change" treatment of the whole combo.
2701        //
2702        // Per-buffer (audit B3): both the dot mark and the change-list ring
2703        // live in the shared `ChangeBank`, so an edit here is visible to
2704        // `` `. `` / `g;` from any other window/split on this same buffer.
2705        let mut bank = self.change_bank.lock().unwrap();
2706        let same_burst = bank.last_edit_end == Some((pre_edit_row, pre_edit_col));
2707        if !same_burst {
2708            bank.last_edit = Some((pre_edit_row, pre_edit_col));
2709            // Append to the change-list ring (skip when the cursor sits on
2710            // the same cell as the last entry — back-to-back keystrokes on
2711            // one column shouldn't pollute the ring). A new edit while
2712            // walking the ring trims the forward half, vim style.
2713            let entry = (pre_edit_row, pre_edit_col);
2714            if bank.list.last() != Some(&entry) {
2715                if let Some(idx) = bank.cursor.take() {
2716                    bank.list.truncate(idx + 1);
2717                }
2718                bank.list.push(entry);
2719                let len = bank.list.len();
2720                if len > crate::types::CHANGE_LIST_MAX {
2721                    bank.list.drain(0..len - crate::types::CHANGE_LIST_MAX);
2722                }
2723            }
2724        }
2725        bank.cursor = None;
2726        bank.last_edit_end = Some(post_edit_pos);
2727        drop(bank);
2728        // Shift / drop marks + jump-list entries (and folds, via
2729        // `rebase_folds` inside `shift_marks_after_edit`) to track the row
2730        // delta the edit produced. Without this, every line-changing
2731        // edit silently invalidates `'a`-style positions.
2732        if delta != 0 {
2733            self.shift_marks_after_edit(pre_row, delta);
2734        }
2735        self.push_buffer_content_to_textarea();
2736        self.mark_content_dirty();
2737        inverse
2738    }
2739
2740    /// Migrate user marks + jumplist entries when an edit at row
2741    /// `edit_start` changes the buffer's row count by `delta` (positive
2742    /// for inserts, negative for deletes). Marks tied to a deleted row
2743    /// are dropped; marks past the affected band shift by `delta`.
2744    fn shift_marks_after_edit(&mut self, edit_start: usize, delta: isize) {
2745        if delta == 0 {
2746            return;
2747        }
2748        // Deleted-row band (only meaningful for delta < 0). Inclusive
2749        // start, exclusive end.
2750        let drop_end = if delta < 0 {
2751            edit_start.saturating_add((-delta) as usize)
2752        } else {
2753            edit_start
2754        };
2755        let shift_threshold = drop_end.max(edit_start.saturating_add(1));
2756
2757        self.buffer
2758            .rebase_marks(edit_start, drop_end, shift_threshold, delta);
2759
2760        // Manual folds (`zf`) are row-ranges living on the same shared
2761        // buffer as marks; without this shift a fold below/above an edit
2762        // keeps stale row numbers forever (#audit-r2 fix 1). `delta != 0`
2763        // here means `mutate_edit` skipped its cursor-band `Invalidate`
2764        // (that only fires for same-row-count edits), so every surviving
2765        // fold reaches this call and is shifted/clipped/grown/shrunk
2766        // (or dropped, if the edit's deleted band fully consumed it) by
2767        // `rebase_folds` precisely instead of just vanishing.
2768        self.buffer
2769            .rebase_folds(edit_start, drop_end, shift_threshold, delta);
2770
2771        // Shift global marks that belong to the current buffer.
2772        let cur_bid = self.current_buffer_id;
2773        let mut global_marks = self.global_marks.lock().unwrap();
2774        let mut global_to_drop: Vec<char> = Vec::new();
2775        for (c, (bid, row, _col)) in global_marks.iter_mut() {
2776            if *bid != cur_bid {
2777                continue;
2778            }
2779            if (edit_start..drop_end).contains(row) {
2780                global_to_drop.push(*c);
2781            } else if *row >= shift_threshold {
2782                *row = ((*row as isize) + delta).max(0) as usize;
2783            }
2784        }
2785        for c in global_to_drop {
2786            global_marks.remove(&c);
2787        }
2788        drop(global_marks);
2789
2790        let shift_jumps = |entries: &mut Vec<(usize, usize)>| {
2791            entries.retain(|(row, _)| !(edit_start..drop_end).contains(row));
2792            for (row, _) in entries.iter_mut() {
2793                if *row >= shift_threshold {
2794                    *row = ((*row as isize) + delta).max(0) as usize;
2795                }
2796            }
2797        };
2798        shift_jumps(&mut self.jump_back);
2799        shift_jumps(&mut self.jump_fwd);
2800    }
2801
2802    /// Reverse-sync helper paired with [`Editor::mutate_edit`]: rebuild
2803    /// the textarea from the buffer's lines + cursor, preserving yank
2804    /// text. Heavy (allocates a fresh `TextArea`) but correct; the
2805    /// textarea field disappears at the end of Phase 7f anyway.
2806    /// No-op since View is the content authority. Retained as a
2807    /// shim so call sites in `mutate_edit` and friends don't have to
2808    /// be ripped in lockstep with the field removal.
2809    pub(crate) fn push_buffer_content_to_textarea(&mut self) {}
2810
2811    /// Single choke-point for "the buffer just changed". Sets the
2812    /// dirty flag and drops the cached `content_arc` snapshot so
2813    /// subsequent reads rebuild from the live textarea. Callers
2814    /// mutating `textarea` directly (e.g. the TUI's bracketed-paste
2815    /// path) must invoke this to keep the cache honest.
2816    pub fn mark_content_dirty(&mut self) {
2817        self.buffer.mark_content_dirty();
2818    }
2819
2820    /// Returns true if content changed since the last call, then clears the flag.
2821    pub fn take_dirty(&mut self) -> bool {
2822        self.buffer.take_dirty()
2823    }
2824
2825    /// Drain the one-shot smooth-scroll hint (#195). True if the last step ran
2826    /// a page/recenter motion the app may animate.
2827    pub fn take_scroll_anim_hint(&mut self) -> bool {
2828        let h = self.scroll_anim_hint;
2829        self.scroll_anim_hint = false;
2830        h
2831    }
2832
2833    // ── Jumplist / viewport-pin (discipline-agnostic seam, #265) ─────────────
2834    //
2835    // Navigation history and viewport pinning are not vim concepts — VSCode's
2836    // Go Back / Go Forward wants the same jumplist, and any discipline can pin
2837    // the viewport. These accessors live on the engine so a future
2838    // helix/vscode discipline reaches them without depending on hjkl-vim. The
2839    // vim *keybindings* on top (`Ctrl-o` / `Ctrl-i`) stay in hjkl-vim.
2840
2841    /// Read-only view of the jumplist as `(jump_back, jump_fwd)`. Newest entry
2842    /// is at the back of each. Backs `:jumps`.
2843    #[allow(clippy::type_complexity)]
2844    pub fn jump_list(&self) -> (&[(usize, usize)], &[(usize, usize)]) {
2845        (&self.jump_back, &self.jump_fwd)
2846    }
2847
2848    /// Position the cursor was at when the user last jumped back. `None`
2849    /// before any jump.
2850    pub fn last_jump_back(&self) -> Option<(usize, usize)> {
2851        self.jump_back.last().copied()
2852    }
2853
2854    /// Read-only view of the jump-back stack.
2855    pub fn jump_back_list(&self) -> &[(usize, usize)] {
2856        &self.jump_back
2857    }
2858
2859    /// Mutable access to the jump-back stack.
2860    pub fn jump_back_list_mut(&mut self) -> &mut Vec<(usize, usize)> {
2861        &mut self.jump_back
2862    }
2863
2864    /// Read-only view of the jump-forward stack.
2865    pub fn jump_fwd_list(&self) -> &[(usize, usize)] {
2866        &self.jump_fwd
2867    }
2868
2869    /// Mutable access to the jump-forward stack.
2870    pub fn jump_fwd_list_mut(&mut self) -> &mut Vec<(usize, usize)> {
2871        &mut self.jump_fwd
2872    }
2873
2874    /// Whether the viewport is pinned (suppresses scroll-follow).
2875    pub fn viewport_pinned(&self) -> bool {
2876        self.viewport_pinned
2877    }
2878
2879    /// Set the viewport-pinned flag.
2880    pub fn set_viewport_pinned(&mut self, v: bool) {
2881        self.viewport_pinned = v;
2882    }
2883
2884    /// Queue an LSP intent for the host to service on the next tick.
2885    pub fn set_pending_lsp(&mut self, intent: Option<crate::editor::LspIntent>) {
2886        self.pending_lsp = intent;
2887    }
2888
2889    /// Record the row range touched by the most recent auto-indent, for the
2890    /// host to pick up via `take_last_indent_range`.
2891    pub fn set_last_indent_range(&mut self, range: Option<(usize, usize)>) {
2892        self.last_indent_range = range;
2893    }
2894
2895    /// Walk cursor into the change list (`g;` / `g,`), or `None` when not
2896    /// walking. Per-buffer (audit B3) — reads the shared [`ChangeBank`].
2897    pub fn change_list_cursor(&self) -> Option<usize> {
2898        self.change_bank.lock().unwrap().cursor
2899    }
2900
2901    /// Set the change-list walk cursor. Per-buffer (audit B3) — writes the
2902    /// shared [`ChangeBank`], so the walk position is visible to (and can be
2903    /// continued from) any other window/split on the same buffer.
2904    pub fn set_change_list_cursor(&mut self, idx: Option<usize>) {
2905        self.change_bank.lock().unwrap().cursor = idx;
2906    }
2907
2908    /// Point this editor at a shared per-buffer changelist bank. UNLIKE the
2909    /// other `set_*_arc` setters (registers/global-marks/last-substitute/
2910    /// abbrevs/search — one Arc for the whole app session), this one is
2911    /// per-buffer: the caller must fetch-or-create the bank keyed by the
2912    /// target `buffer_id` and swap it in whenever the editor's buffer
2913    /// changes (audit B3). See [`ChangeBank`].
2914    pub fn set_change_bank_arc(&mut self, bank: std::sync::Arc<std::sync::Mutex<ChangeBank>>) {
2915        self.change_bank = bank;
2916    }
2917
2918    /// Arm the one-shot hint that the next scroll should be animated.
2919    pub fn set_scroll_anim_hint(&mut self, v: bool) {
2920        self.scroll_anim_hint = v;
2921    }
2922
2923    /// Set the read-only view overlay (Normal / Blame).
2924    pub fn set_view_mode(&mut self, v: crate::ViewMode) {
2925        self.view = v;
2926    }
2927
2928    /// The active abbreviation table. Returns an owned clone (the value
2929    /// lives behind a shared `Mutex`, so a borrow can't outlive the guard —
2930    /// mirrors [`Editor::global_marks_iter`]).
2931    pub fn abbrevs(&self) -> Vec<crate::abbrev::Abbrev> {
2932        self.abbrevs.lock().unwrap().clone()
2933    }
2934
2935    /// Whether any abbreviations are defined. Cheap emptiness check that
2936    /// locks but does NOT clone — the insert hot path calls this per
2937    /// keystroke, so it must not allocate. Use instead of `abbrevs().is_empty()`.
2938    pub fn abbrevs_is_empty(&self) -> bool {
2939        self.abbrevs.lock().unwrap().is_empty()
2940    }
2941
2942    /// Point this editor at a shared abbreviations bank. All editors in the
2943    /// app share one bank (mirrors [`Editor::set_last_substitute_arc`]) so
2944    /// `:iabbrev` / `:abbreviate` defined in one window/split expand in
2945    /// every other window — vim's abbreviations are session-global, not
2946    /// per-window.
2947    pub fn set_abbrevs_arc(
2948        &mut self,
2949        abbrevs: std::sync::Arc<std::sync::Mutex<Vec<crate::abbrev::Abbrev>>>,
2950    ) {
2951        self.abbrevs = abbrevs;
2952    }
2953
2954    /// Autopair's queued close-brackets, as `(row, col, ch)`. A discipline's
2955    /// insert path consumes a queued close when the user types the matching
2956    /// character instead of inserting a second one.
2957    pub fn pending_closes(&self) -> &[(usize, usize, char)] {
2958        &self.pending_closes
2959    }
2960
2961    /// Mutable access to autopair's queued close-brackets.
2962    pub fn pending_closes_mut(&mut self) -> &mut Vec<(usize, usize, char)> {
2963        &mut self.pending_closes
2964    }
2965
2966    /// Whether the unnamed register's content is linewise.
2967    pub fn yank_linewise(&self) -> bool {
2968        self.yank_linewise
2969    }
2970
2971    /// Set the linewise flag for the unnamed register.
2972    pub fn set_yank_linewise(&mut self, v: bool) {
2973        self.yank_linewise = v;
2974    }
2975
2976    // ── Search state (discipline-agnostic seam, #265) ────────────────────────
2977    //
2978    // Every editor has find. These live on the engine so a helix/vscode
2979    // discipline reaches the pattern, direction and history without depending
2980    // on hjkl-vim. The vim *keybindings* on top (`/`, `?`, `n`, `N`, `*`) stay
2981    // in hjkl-vim.
2982
2983    /// The live `/` or `?` search-prompt state, if a prompt is open.
2984    pub fn search_prompt_state(&self) -> Option<&crate::search::SearchPrompt> {
2985        self.search_prompt.as_ref()
2986    }
2987
2988    /// Mutable access to the live search-prompt state.
2989    pub fn search_prompt_state_mut(&mut self) -> Option<&mut crate::search::SearchPrompt> {
2990        self.search_prompt.as_mut()
2991    }
2992
2993    /// Take (and close) the search-prompt state.
2994    pub fn take_search_prompt_state(&mut self) -> Option<crate::search::SearchPrompt> {
2995        self.search_prompt.take()
2996    }
2997
2998    /// Install (or clear) the search-prompt state.
2999    pub fn set_search_prompt_state(&mut self, prompt: Option<crate::search::SearchPrompt>) {
3000        self.search_prompt = prompt;
3001    }
3002
3003    /// The last committed search pattern, for `n` / `N` (or Find Next).
3004    /// Returns an owned clone (the value lives behind a shared `Mutex`, so a
3005    /// borrow can't outlive the guard — mirrors [`Editor::global_marks_iter`]).
3006    pub fn last_search_pattern(&self) -> Option<String> {
3007        self.search.lock().unwrap().last.clone()
3008    }
3009
3010    /// Set the last search pattern without touching direction or highlight.
3011    pub fn set_last_search_pattern_only(&mut self, pattern: Option<String>) {
3012        self.search.lock().unwrap().last = pattern;
3013    }
3014
3015    /// Set the last search direction without touching the pattern.
3016    pub fn set_last_search_forward_only(&mut self, forward: bool) {
3017        self.search.lock().unwrap().forward = forward;
3018    }
3019
3020    /// The search history (oldest first). Returns an owned clone (the value
3021    /// lives behind a shared `Mutex`, so a borrow can't outlive the guard).
3022    pub fn search_history(&self) -> Vec<String> {
3023        self.search.lock().unwrap().history.clone()
3024    }
3025
3026    /// Cursor position while walking search history with Up/Down.
3027    pub fn search_history_cursor(&self) -> Option<usize> {
3028        self.search.lock().unwrap().history_cursor
3029    }
3030
3031    /// Set the search-history walk cursor.
3032    pub fn set_search_history_cursor(&mut self, idx: Option<usize>) {
3033        self.search.lock().unwrap().history_cursor = idx;
3034    }
3035
3036    // ── Input timing (discipline-agnostic seam) ──────────────────────────────
3037    //
3038    // Any chorded FSM needs a timeout clock, not just vim.
3039
3040    /// Instant of the last input, when the host supplies a monotonic clock.
3041    pub fn last_input_at(&self) -> Option<std::time::Instant> {
3042        self.last_input_at
3043    }
3044
3045    /// Set the instant of the last input.
3046    pub fn set_last_input_at(&mut self, t: Option<std::time::Instant>) {
3047        self.last_input_at = t;
3048    }
3049
3050    /// Host-supplied elapsed time at the last input (no_std hosts).
3051    pub fn last_input_host_at(&self) -> Option<core::time::Duration> {
3052        self.last_input_host_at
3053    }
3054
3055    /// Set the host-supplied elapsed time at the last input.
3056    pub fn set_last_input_host_at(&mut self, d: Option<core::time::Duration>) {
3057        self.last_input_host_at = d;
3058    }
3059
3060    // ── Scrolling (discipline-agnostic seam, #265) ───────────────────────────
3061    //
3062    // Scrolling a viewport is not a vim concept — every discipline does it.
3063    // These carry zero vim FSM state (the one field they used to touch,
3064    // `scroll_anim_hint`, now lives on the Editor), so they belong here. The
3065    // vim *keybindings* on top (`Ctrl-F`/`Ctrl-B`, `Ctrl-D`/`Ctrl-U`,
3066    // `Ctrl-E`/`Ctrl-Y`) stay in hjkl-vim.
3067
3068    /// Rows spanned by half a viewport, times `count` (min 1).
3069    pub fn viewport_half_rows(&self, count: usize) -> usize {
3070        let h = self.viewport_height_value() as usize;
3071        (h / 2).max(1).saturating_mul(count.max(1))
3072    }
3073
3074    /// Rows spanned by a full viewport (less a two-line overlap), times
3075    /// `count` (min 1).
3076    pub fn viewport_full_rows(&self, count: usize) -> usize {
3077        let h = self.viewport_height_value() as usize;
3078        h.saturating_sub(2).max(1).saturating_mul(count.max(1))
3079    }
3080
3081    /// Move the cursor `delta` rows (clamped to the buffer), landing on the
3082    /// first non-blank of the target row and resetting the sticky column.
3083    pub fn scroll_cursor_rows(&mut self, delta: isize) {
3084        if delta == 0 {
3085            return;
3086        }
3087        self.sync_buffer_content_from_textarea();
3088        let (row, _) = self.cursor();
3089        let last_row = buf_row_count(&self.buffer).saturating_sub(1);
3090        let target = (row as isize + delta).max(0).min(last_row as isize) as usize;
3091        buf_set_cursor_rc(&mut self.buffer, target, 0);
3092        crate::motions::move_first_non_blank(&mut self.buffer);
3093        let pos = buf_cursor_pos(&self.buffer);
3094        let line = buf_line(&self.buffer, pos.row).unwrap_or_default();
3095        self.sticky_col = Some(char_col_to_visual_col(
3096            &line,
3097            pos.col,
3098            self.settings().tabstop,
3099        ));
3100    }
3101
3102    /// Scroll the cursor by one full viewport height (height − 2 rows,
3103    /// preserving a two-line overlap). `count` multiplies the step.
3104    pub fn scroll_full_page(&mut self, dir: crate::types::ScrollDir, count: usize) {
3105        self.scroll_anim_hint = true;
3106        let rows = self.viewport_full_rows(count) as isize;
3107        match dir {
3108            crate::types::ScrollDir::Down => self.scroll_cursor_rows(rows),
3109            crate::types::ScrollDir::Up => self.scroll_cursor_rows(-rows),
3110        }
3111    }
3112
3113    /// Scroll the cursor by half the viewport height. `count` multiplies.
3114    pub fn scroll_half_page(&mut self, dir: crate::types::ScrollDir, count: usize) {
3115        self.scroll_anim_hint = true;
3116        let rows = self.viewport_half_rows(count) as isize;
3117        match dir {
3118            crate::types::ScrollDir::Down => self.scroll_cursor_rows(rows),
3119            crate::types::ScrollDir::Up => self.scroll_cursor_rows(-rows),
3120        }
3121    }
3122
3123    /// Scroll the viewport `count` lines without moving the cursor (the cursor
3124    /// is clamped into the new visible region if it would fall outside).
3125    pub fn scroll_line(&mut self, dir: crate::types::ScrollDir, count: usize) {
3126        let n = count.max(1);
3127        let total = buf_row_count(&self.buffer);
3128        let last = total.saturating_sub(1);
3129        let h = self.viewport_height_value() as usize;
3130        let cur_top = self.host().viewport().top_row;
3131        let new_top = match dir {
3132            crate::types::ScrollDir::Down => (cur_top + n).min(last),
3133            crate::types::ScrollDir::Up => cur_top.saturating_sub(n),
3134        };
3135        self.set_viewport_top(new_top);
3136        // Clamp cursor to stay within the new visible region.
3137        let (row, _) = self.cursor();
3138        let bot = (new_top + h).saturating_sub(1).min(last);
3139        let clamped = row.max(new_top).min(bot);
3140        if clamped != row {
3141            // `<C-e>` / `<C-y>` are screen-line vertical motions, so pushing
3142            // the cursor onto a new row follows the `j` / `k` rule: aim at
3143            // `curswant`, clamp to the row, keep the un-clamped want. This
3144            // used to re-use the old column verbatim, which both ignored
3145            // `curswant` and parked the cursor past end-of-line on a shorter
3146            // row. Found by the `esc_returns_to_normal` proptest via the
3147            // phase-0 curswant assertion.
3148            self.move_cursor(crate::cursor_move::Move::Vertical { row: clamped });
3149        }
3150    }
3151
3152    /// Drain the queue of [`crate::types::ContentEdit`]s emitted since
3153    /// the last call. Each entry corresponds to a single buffer
3154    /// mutation funnelled through [`Editor::mutate_edit`]; block edits
3155    /// fan out to one entry per row touched.
3156    ///
3157    /// Hosts call this each frame (after [`Editor::take_content_reset`])
3158    /// to fan edits into a tree-sitter parser via `Tree::edit`.
3159    pub fn take_content_edits(&mut self) -> Vec<crate::types::ContentEdit> {
3160        self.buffer.take_pending_content_edits()
3161    }
3162
3163    /// Returns `true` if a bulk buffer replacement happened since the
3164    /// last call (e.g. `set_content` / `restore` / undo restore), then
3165    /// clears the flag. When this returns `true`, hosts should drop
3166    /// any retained syntax tree before consuming
3167    /// [`Editor::take_content_edits`].
3168    pub fn take_content_reset(&mut self) -> bool {
3169        self.buffer.take_pending_content_reset()
3170    }
3171
3172    /// Pull-model coarse change observation. If content changed since
3173    /// the last call, returns `Some(Arc<String>)` with the new content
3174    /// and clears the dirty flag; otherwise returns `None`.
3175    ///
3176    /// Hosts that need fine-grained edit deltas (e.g., DOM patching at
3177    /// the character level) should diff against their own previous
3178    /// snapshot. The SPEC `take_changes() -> Vec<EditOp>` API lands
3179    /// once every edit path inside the engine is instrumented; this
3180    /// coarse form covers the pull-model use case in the meantime.
3181    pub fn take_content_change(&mut self) -> Option<std::sync::Arc<String>> {
3182        if !self.buffer.content_dirty() {
3183            return None;
3184        }
3185        let arc = self.content_arc();
3186        self.buffer.set_content_dirty(false);
3187        Some(arc)
3188    }
3189
3190    /// Width in cells of the line-number gutter for the current buffer
3191    /// and settings. Matches what [`Editor::cursor_screen_pos`] reserves
3192    /// in front of the text column. Returns `0` when both `number` and
3193    /// `relativenumber` are off.
3194    pub fn lnum_width(&self) -> u16 {
3195        if self.settings.number || self.settings.relativenumber {
3196            let needed = buf_row_count(&self.buffer).to_string().len() + 1;
3197            needed.max(self.settings.numberwidth) as u16
3198        } else {
3199            0
3200        }
3201    }
3202
3203    /// Returns the cursor's row within the visible textarea (0-based), updating
3204    /// the stored viewport top so subsequent calls remain accurate.
3205    pub fn cursor_screen_row(&mut self, height: u16) -> u16 {
3206        let cursor = buf_cursor_row(&self.buffer);
3207        let top = self.host.viewport().top_row;
3208        cursor
3209            .saturating_sub(top)
3210            .min((height as usize).saturating_sub(1)) as u16
3211    }
3212
3213    /// Returns the cursor's screen position `(x, y)` for the textarea
3214    /// described by `(area_x, area_y, area_width, area_height)`.
3215    /// Accounts for line-number gutter, viewport scroll, and any extra
3216    /// gutter width to the left of the number column (sign column, fold
3217    /// column). Returns `None` if the cursor is outside the visible
3218    /// viewport. Always available (engine-native; no ratatui dependency).
3219    ///
3220    /// `extra_gutter_width` is added to the number-column width before
3221    /// computing the cursor x position. Callers (e.g. `apps/hjkl/src/render.rs`)
3222    /// pass `sign_w + fold_w` here so the cursor lands on the correct cell
3223    /// when a dedicated sign or fold column is present.
3224    ///
3225    /// Renamed from `cursor_screen_pos_xywh` in 0.0.32.
3226    pub fn cursor_screen_pos(
3227        &self,
3228        area_x: u16,
3229        area_y: u16,
3230        area_width: u16,
3231        area_height: u16,
3232        extra_gutter_width: u16,
3233    ) -> Option<(u16, u16)> {
3234        let (pos_row, pos_col) = buf_cursor_rc(&self.buffer);
3235        let v = self.host.viewport();
3236        if pos_row < v.top_row || pos_col < v.top_col {
3237            return None;
3238        }
3239        let lnum_width = self.lnum_width();
3240        // Full offset from the left edge of the window to the first text cell.
3241        let gutter_total = lnum_width + extra_gutter_width;
3242        // Screen row delta: delegate to the single fold- and wrap-aware
3243        // calculator that already drives scrolling + scrolloff, rather than
3244        // recomputing `pos_row - top_row` here. That naive delta ignored rows
3245        // collapsed by closed folds, painting the cursor block N rows too low
3246        // while the (fold-aware) text + line-highlight rendered correctly.
3247        // One source of truth → no drift between scroll math and cursor math. (#244)
3248        let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&self.buffer);
3249        let dy = crate::viewport_math::cursor_screen_row_from(&self.buffer, &folds, v, v.top_row)?
3250            as u16;
3251        // Convert char column to visual column so cursor lands on the
3252        // correct cell when the line contains tabs (which the renderer
3253        // expands to TAB_WIDTH stops). Tab width must match the renderer.
3254        let cursor_rope = self.buffer.rope();
3255        let pos_row_safe = pos_row.min(cursor_rope.len_lines().saturating_sub(1));
3256        let line = hjkl_buffer::rope_line_str(&cursor_rope, pos_row_safe);
3257        let tab_width = if v.tab_width == 0 {
3258            4
3259        } else {
3260            v.tab_width as usize
3261        };
3262        let visual_pos = visual_col_for_char(&line, pos_col, tab_width);
3263        let visual_top = visual_col_for_char(&line, v.top_col, tab_width);
3264        let dx = (visual_pos - visual_top) as u16;
3265        if dy >= area_height || dx + gutter_total >= area_width {
3266            return None;
3267        }
3268        Some((area_x + gutter_total + dx, area_y + dy))
3269    }
3270
3271    /// Discipline-agnostic coarse mode for app chrome (status badge, cursor
3272    /// shape). App code that only needs "inserting / selecting / idle" — not the
3273    /// precise vim mode — should read this so it works identically under any
3274    /// keybinding discipline (vim, vscode, future helix/emacs). See
3275    /// [`crate::CoarseMode`] (epic #265 G3). Today this projects from the vim
3276    /// mode; once FSM state is pluggable each discipline supplies its own.
3277    pub fn coarse_mode(&self) -> crate::CoarseMode {
3278        self.discipline.coarse_mode()
3279    }
3280
3281    /// The secondary selections, in char columns. Empty for a single-cursor
3282    /// editor.
3283    ///
3284    /// The primary selection is *not* included: its head is [`Editor::cursor`]
3285    /// and its anchor lives in the discipline — see the `extra_selections` field
3286    /// docs for why.
3287    pub fn extra_selections(&self) -> &[crate::selection_shift::Sel] {
3288        &self.extra_selections
3289    }
3290
3291    /// The **heads** of the secondary selections — the carets a user sees.
3292    ///
3293    /// Convenience view over [`Editor::extra_selections`] for callers that only
3294    /// care where the carets are (rendering, tests).
3295    pub fn extra_cursors(&self) -> Vec<hjkl_buffer::Position> {
3296        self.extra_selections.iter().map(|s| s.head).collect()
3297    }
3298
3299    /// Replace the whole secondary set.
3300    ///
3301    /// Selections whose head duplicates the primary head, or an earlier entry's
3302    /// head, are dropped: two carets on one spot would apply every edit twice at
3303    /// the same place. Same invariant [`Editor::add_cursor`] enforces, applied to
3304    /// a bulk write — a discipline recomputing every selection after a motion
3305    /// (helix does this on every keystroke) must not be able to smuggle a
3306    /// duplicate in through the back door.
3307    ///
3308    /// Any two selections whose *ranges* overlap (not just their heads) are
3309    /// merged into their union rather than kept as separate entries (audit
3310    /// A7) — [`Editor::edit_at_all_selections`]'s bottom-up fan-out assumes
3311    /// selections are disjoint, and an overlapping pair would corrupt each
3312    /// other's still-queued coordinates as edits land.
3313    pub fn set_extra_selections(&mut self, sels: Vec<crate::selection_shift::Sel>) {
3314        let (row, col) = self.cursor();
3315        let primary = hjkl_buffer::Position::new(row, col);
3316        let mut deduped: Vec<crate::selection_shift::Sel> = Vec::new();
3317        for s in sels {
3318            if s.head == primary || deduped.iter().any(|e| e.head == s.head) {
3319                continue;
3320            }
3321            deduped.push(s);
3322        }
3323        self.extra_selections = crate::selection_shift::merge_overlapping(deduped);
3324    }
3325
3326    /// Add a secondary selection. Same dedup rule as [`Editor::add_cursor`],
3327    /// plus the overlap-merge guard documented on [`Editor::set_extra_selections`]
3328    /// (audit A7): a selection whose range overlaps an existing secondary is
3329    /// merged into it instead of being kept as a second, aliasing entry.
3330    pub fn add_selection(&mut self, sel: crate::selection_shift::Sel) {
3331        let (row, col) = self.cursor();
3332        if sel.head == hjkl_buffer::Position::new(row, col)
3333            || self.extra_selections.iter().any(|s| s.head == sel.head)
3334        {
3335            return;
3336        }
3337        self.extra_selections.push(sel);
3338        self.extra_selections =
3339            crate::selection_shift::merge_overlapping(std::mem::take(&mut self.extra_selections));
3340    }
3341
3342    /// Add a secondary cursor: a zero-width selection at `pos`. Ignores a
3343    /// position that duplicates the primary head or an existing secondary head,
3344    /// so a set never carries two carets at one spot — that would apply an edit
3345    /// twice at the same place.
3346    pub fn add_cursor(&mut self, pos: hjkl_buffer::Position) {
3347        self.add_selection(crate::selection_shift::Sel::caret(pos));
3348    }
3349
3350    /// Drop every secondary selection, collapsing back to the primary.
3351    pub fn clear_extra_cursors(&mut self) {
3352        self.extra_selections.clear();
3353    }
3354
3355    /// Apply an edit at **every** cursor — the primary and all secondaries —
3356    /// and leave each cursor where its own edit left it (#63).
3357    ///
3358    /// `make` is handed each cursor's position and returns the edit to apply
3359    /// there, so the caller writes the edit once and it fans out:
3360    ///
3361    /// ```ignore
3362    /// ed.edit_at_all_cursors(|at| Edit::InsertStr { at, text: "x".into() });
3363    /// ```
3364    ///
3365    /// Returns the inverse of each applied edit, in application order, so a
3366    /// caller can push them as one undo step. This does **not** touch the undo
3367    /// stack itself — `mutate_edit` never does, and a multi-cursor keystroke is
3368    /// one user action, so the discipline pushes undo once before calling.
3369    ///
3370    /// # Why the order matters
3371    ///
3372    /// Edits are applied **bottom-up** (last cursor in the document first). An
3373    /// edit at position P only moves positions at or after P, so working
3374    /// backwards leaves every not-yet-visited cursor's coordinates still valid.
3375    /// Going top-down would invalidate them all after the first edit.
3376    ///
3377    /// Each cursor that has already been edited is parked in `extra_cursors`,
3378    /// so [`Editor::mutate_edit`]'s shift keeps it correct as the remaining
3379    /// (earlier) edits land. The bookkeeping is the same machinery, reused.
3380    ///
3381    /// # Degradation
3382    ///
3383    /// If any cursor becomes untrackable mid-apply (see `selection_shift`), the
3384    /// secondaries are dropped and the editor collapses to the primary rather
3385    /// than carrying on with a caret that no longer knows where it is.
3386    pub fn edit_at_all_cursors(
3387        &mut self,
3388        make: impl Fn(hjkl_buffer::Position) -> hjkl_buffer::Edit,
3389    ) -> Vec<hjkl_buffer::Edit> {
3390        let (pr, pc) = self.cursor();
3391        let primary = hjkl_buffer::Position::new(pr, pc);
3392        let (inverses, _) = self.edit_at_all_selections(primary, |s| make(s.head));
3393        inverses
3394    }
3395
3396    /// Apply an edit at **every selection** — the primary and all secondaries —
3397    /// where `make` sees the whole selection, not just its head (#63).
3398    ///
3399    /// This is what an operator needs: `d` on three selections has to delete
3400    /// three *ranges*, and only the caller-visible [`Sel`] carries both ends.
3401    /// [`Editor::edit_at_all_cursors`] is the caret-only special case of this.
3402    ///
3403    /// `primary_anchor` is passed in — and the primary's *new* anchor is returned
3404    /// — because the primary selection's anchor lives in the discipline's state,
3405    /// not the engine's (see the `extra_selections` field docs).
3406    ///
3407    /// Returns `(inverse of each applied edit in application order, new primary
3408    /// anchor)`. This does **not** touch the undo stack — `mutate_edit` never
3409    /// does, and a multi-cursor keystroke is one user action, so the discipline
3410    /// pushes undo once before calling.
3411    ///
3412    /// # Why the order matters
3413    ///
3414    /// Edits are applied **bottom-up** (last selection in the document first). An
3415    /// edit at position P only moves positions at or after P, so working
3416    /// backwards leaves every not-yet-visited selection's coordinates still valid.
3417    /// Going top-down would invalidate them all after the first edit.
3418    ///
3419    /// Each selection that has already been edited is parked in
3420    /// `extra_selections`, so [`Editor::mutate_edit`]'s shift keeps it correct as
3421    /// the remaining (earlier) edits land.
3422    ///
3423    /// # What happens to the anchors
3424    ///
3425    /// Each selection's anchor is shifted through *its own* edit with the same
3426    /// insertion-point semantics [`crate::selection_shift`] uses everywhere: an
3427    /// anchor swallowed by a deletion collapses onto the deletion start, which is
3428    /// exactly where the head lands — so `d` / `c` leave a caret at each edit
3429    /// site, with no bookkeeping. An anchor sitting exactly at an insertion point
3430    /// slides right with the text. A caller that needs a selection *preserved*
3431    /// across a same-length rewrite (helix's `~`, `>`) should re-set the
3432    /// selections afterwards via [`Editor::set_extra_selections`] rather than
3433    /// rely on that shift.
3434    ///
3435    /// # Degradation
3436    ///
3437    /// If any selection becomes untrackable mid-apply (see `selection_shift`), the
3438    /// secondaries are dropped and the editor collapses to the primary rather than
3439    /// carrying on with a selection that no longer knows where it is.
3440    ///
3441    /// [`Sel`]: crate::selection_shift::Sel
3442    pub fn edit_at_all_selections(
3443        &mut self,
3444        primary_anchor: hjkl_buffer::Position,
3445        make: impl Fn(crate::selection_shift::Sel) -> hjkl_buffer::Edit,
3446    ) -> (Vec<hjkl_buffer::Edit>, hjkl_buffer::Position) {
3447        use crate::selection_shift::Sel;
3448
3449        let (pr, pc) = self.cursor();
3450        let primary = Sel::new(primary_anchor, hjkl_buffer::Position::new(pr, pc));
3451
3452        let mut all: Vec<Sel> = std::iter::once(primary)
3453            .chain(self.extra_selections.iter().copied())
3454            .collect();
3455        // Bottom-up by where each selection's edit *starts* — its earlier end.
3456        // For a caret that is just the head, so this is the same order as before.
3457        all.sort_by_key(|s| std::cmp::Reverse((s.start().row, s.start().col)));
3458
3459        // Rebuilt as we go: a selection lands in here the moment its edit is done,
3460        // which enrols it in the shift for every later edit.
3461        self.extra_selections.clear();
3462
3463        let mut inverses = Vec::with_capacity(all.len());
3464        let mut primary_idx: Option<usize> = None;
3465        let mut lost_a_selection = false;
3466
3467        for (i, s) in all.iter().copied().enumerate() {
3468            // Every previous iteration should have parked exactly one selection.
3469            // If the count slipped, `mutate_edit` dropped one it could not track.
3470            if self.extra_selections.len() != i {
3471                lost_a_selection = true;
3472                break;
3473            }
3474            let edit = make(s);
3475            // The anchor has to be shifted against the PRE-edit geometry, same as
3476            // the parked selections are — so read the metrics before applying.
3477            let rows = buf_row_count(&self.buffer);
3478            let lens: Vec<usize> = (0..rows).map(|r| buf_line_chars(&self.buffer, r)).collect();
3479            let shifted_anchor = crate::selection_shift::shift_position(
3480                s.anchor,
3481                &edit,
3482                |r| lens.get(r).copied().unwrap_or(0),
3483                rows,
3484            );
3485
3486            self.set_cursor_quiet(s.head.row, s.head.col);
3487            inverses.push(self.mutate_edit(edit));
3488            let (nr, nc) = self.cursor();
3489
3490            let Some(anchor) = shifted_anchor else {
3491                lost_a_selection = true;
3492                break;
3493            };
3494            if s == primary && primary_idx.is_none() {
3495                primary_idx = Some(self.extra_selections.len());
3496            }
3497            self.extra_selections
3498                .push(Sel::new(anchor, hjkl_buffer::Position::new(nr, nc)));
3499        }
3500
3501        match (lost_a_selection, primary_idx) {
3502            (false, Some(idx)) if idx < self.extra_selections.len() => {
3503                // Pull the primary back out of the parked set; the rest stay.
3504                let landed = self.extra_selections.remove(idx);
3505                self.set_cursor_quiet(landed.head.row, landed.head.col);
3506                (inverses, landed.anchor)
3507            }
3508            _ => {
3509                // Something went untrackable: collapse to a single selection rather
3510                // than leave one pointing at text it no longer owns.
3511                self.extra_selections.clear();
3512                let (row, col) = self.cursor();
3513                (inverses, hjkl_buffer::Position::new(row, col))
3514            }
3515        }
3516    }
3517
3518    /// The installed discipline's FSM state, type-erased.
3519    ///
3520    /// A discipline crate reaches its own concrete state by downcasting:
3521    /// `ed.discipline().as_any().downcast_ref::<VimState>()`.
3522    pub fn discipline(&self) -> &dyn crate::DisciplineState {
3523        &*self.discipline
3524    }
3525
3526    /// Mutable counterpart of [`Editor::discipline`].
3527    pub fn discipline_mut(&mut self) -> &mut dyn crate::DisciplineState {
3528        &mut *self.discipline
3529    }
3530
3531    /// Install a keyboard discipline, replacing whatever was there.
3532    ///
3533    /// Host apps call this once at construction (e.g.
3534    /// `hjkl_vim::install_vim_discipline(&mut ed)`); an `Editor` that never
3535    /// receives discipline input keeps the default
3536    /// [`NoDiscipline`](crate::NoDiscipline).
3537    pub fn set_discipline(&mut self, discipline: Box<dyn crate::DisciplineState>) {
3538        self.discipline = discipline;
3539    }
3540
3541    /// The active read-only view overlay (see [`crate::ViewMode`]). Independent
3542    /// of [`Editor::vim_mode`]; the host renderer reads this as the source of
3543    /// truth for whether to draw the git-blame framing.
3544    pub fn view_mode(&self) -> crate::ViewMode {
3545        self.view
3546    }
3547
3548    /// `true` when the git-blame read-only overlay is active. Masked on the
3549    /// input mode: BLAME is only meaningful in Normal, so this returns `false`
3550    /// the instant the editor enters Insert/Visual/etc., even before the
3551    /// overlay flag is dropped. Use this for both rendering and mode-label.
3552    pub fn is_blame(&self) -> bool {
3553        self.view == crate::ViewMode::Blame && self.coarse_mode() == crate::CoarseMode::Normal
3554    }
3555
3556    /// Enter the git-blame read-only overlay. No-op unless the editor is in
3557    /// Normal mode (BLAME is a Normal-only view). While active, every mutation
3558    /// funnel is blocked and the host renders the per-commit framing.
3559    pub fn enter_blame(&mut self) {
3560        if self.coarse_mode() == crate::CoarseMode::Normal {
3561            self.view = crate::ViewMode::Blame;
3562        }
3563    }
3564
3565    /// Leave the git-blame overlay, returning to a plain Normal view. Idempotent.
3566    pub fn exit_blame(&mut self) {
3567        self.view = crate::ViewMode::Normal;
3568    }
3569
3570    /// Bounds of the active visual-block rectangle as
3571    /// `(top_row, bot_row, left_col, right_col)` — all inclusive.
3572    /// `None` when we're not in VisualBlock mode.
3573    /// Read-only view of the live `/` or `?` prompt. `None` outside
3574    /// search-prompt mode.
3575    pub fn search_prompt(&self) -> Option<&crate::search::SearchPrompt> {
3576        self.search_prompt.as_ref()
3577    }
3578
3579    /// Most recent committed search pattern (persists across `n` / `N`
3580    /// and across prompt exits). `None` before the first search. Returns an
3581    /// owned clone (the value lives behind a shared `Mutex`, so a borrow
3582    /// can't outlive the guard — mirrors [`Editor::global_marks_iter`]).
3583    pub fn last_search(&self) -> Option<String> {
3584        self.search.lock().unwrap().last.clone()
3585    }
3586
3587    /// Whether the last committed search was a forward `/` (`true`) or
3588    /// a backward `?` (`false`). `n` and `N` consult this to honour the
3589    /// direction the user committed.
3590    pub fn last_search_forward(&self) -> bool {
3591        self.search.lock().unwrap().forward
3592    }
3593
3594    /// Set the most recent committed search text + direction. Used by
3595    /// host-driven prompts (e.g. apps/hjkl's `/` `?` prompt that lives
3596    /// outside the engine's vim FSM) so `n` / `N` repeat the host's
3597    /// most recent commit with the right direction. Pass `None` /
3598    /// `true` to clear.
3599    pub fn set_last_search(&mut self, text: Option<String>, forward: bool) {
3600        let mut bank = self.search.lock().unwrap();
3601        bank.last = text;
3602        bank.forward = forward;
3603    }
3604
3605    /// Point this editor at a shared search bank. All editors in the app
3606    /// share one bank (mirrors [`Editor::set_last_substitute_arc`]) so `/`
3607    /// / `?` committed in one window/split and `n` / `N` typed in another
3608    /// see the same pattern — vim's last search (the `"/` register) is
3609    /// session-global, not per-window.
3610    pub fn set_search_arc(&mut self, search: std::sync::Arc<std::sync::Mutex<SearchBank>>) {
3611        self.search = search;
3612    }
3613
3614    /// The most recent successful `:s` command. `None` before the first substitute.
3615    /// Used by `:&` / `:&&` to repeat it. Returns an owned clone (the value
3616    /// lives behind a shared `Mutex`, so a borrow can't outlive the guard —
3617    /// mirrors [`Editor::global_marks_iter`]).
3618    pub fn last_substitute(&self) -> Option<crate::substitute::SubstituteCmd> {
3619        self.last_substitute.lock().unwrap().clone()
3620    }
3621
3622    /// The previous `:s` replacement text, or `""` when no substitute has run
3623    /// yet. Feeds the magic `~` expansion on the PATTERN side of `:s` and
3624    /// `/`/`?` searches — pass it to
3625    /// [`crate::search::resolve_case_mode`]. (The replacement-side `~`/`&`
3626    /// features read the same [`Editor::last_substitute`] bank.)
3627    pub fn last_substitute_replacement(&self) -> String {
3628        self.last_substitute
3629            .lock()
3630            .unwrap()
3631            .as_ref()
3632            .map(|c| c.replacement.clone())
3633            .unwrap_or_default()
3634    }
3635
3636    /// Store the last successful substitute so `:&` / `:&&` can repeat it.
3637    pub fn set_last_substitute(&mut self, cmd: crate::substitute::SubstituteCmd) {
3638        *self.last_substitute.lock().unwrap() = Some(cmd);
3639    }
3640
3641    /// Point this editor at a shared last-substitute bank. All editors in
3642    /// the app share one bank (mirrors [`Editor::set_global_marks_arc`]) so
3643    /// `:&` run in one window repeats the `:s` most recently executed in any
3644    /// window — vim's last substitute is session-global, not per-window.
3645    pub fn set_last_substitute_arc(
3646        &mut self,
3647        last_substitute: std::sync::Arc<std::sync::Mutex<Option<crate::substitute::SubstituteCmd>>>,
3648    ) {
3649        self.last_substitute = last_substitute;
3650    }
3651
3652    /// Number of rows (lines) in the buffer.
3653    ///
3654    /// Convenience accessor for call sites that only need the row count without
3655    /// routing through the `Query` trait directly (e.g. the VSCode selection
3656    /// dispatcher computing buffer-end positions).
3657    pub fn row_count(&self) -> usize {
3658        buf_row_count(&self.buffer)
3659    }
3660
3661    /// Row `row` as an owned `String` (no trailing newline), or `None` when
3662    /// `row` is out of bounds.
3663    ///
3664    /// Mode-agnostic buffer read. Hosts and discipline crates (e.g. the vim
3665    /// accessors on `hjkl_vim::VimEditorExt`) use this instead of reaching for
3666    /// the engine's private `buf_line` helper.
3667    pub fn line(&self, row: usize) -> Option<String> {
3668        buf_line(&self.buffer, row)
3669    }
3670
3671    pub fn content(&self) -> String {
3672        let n = buf_row_count(&self.buffer);
3673        let mut s = String::new();
3674        for r in 0..n {
3675            if r > 0 {
3676                s.push('\n');
3677            }
3678            s.push_str(&crate::types::Query::line(&self.buffer, r as u32));
3679        }
3680        s.push('\n');
3681        s
3682    }
3683
3684    /// Same logical output as [`content`], but returns a cached
3685    /// `Arc<String>` so back-to-back reads within an un-mutated window
3686    /// are ref-count bumps instead of multi-MB joins. The cache is
3687    /// invalidated by every [`mark_content_dirty`] call.
3688    pub fn content_arc(&mut self) -> std::sync::Arc<String> {
3689        if let Some(arc) = self.buffer.cached_editor_content() {
3690            return arc;
3691        }
3692        let arc = std::sync::Arc::new(self.content());
3693        self.buffer
3694            .set_cached_editor_content(std::sync::Arc::clone(&arc));
3695        arc
3696    }
3697
3698    pub fn set_content(&mut self, text: &str) {
3699        let mut lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
3700        while lines.last().is_some_and(|l| l.is_empty()) {
3701            lines.pop();
3702        }
3703        if lines.is_empty() {
3704            lines.push(String::new());
3705        }
3706        let _ = lines;
3707        crate::types::BufferEdit::replace_all(&mut self.buffer, text);
3708        self.buffer.clear_undo_redo();
3709        // Whole-buffer replace supersedes any queued ContentEdits.
3710        self.buffer.clear_pending_content_edits();
3711        self.buffer.set_pending_content_reset(true);
3712        self.mark_content_dirty();
3713    }
3714
3715    /// Whole-buffer replace that **preserves the undo history**.
3716    ///
3717    /// Equivalent to [`Editor::set_content`] but pushes the current buffer
3718    /// state onto the undo stack first, so a subsequent `u` walks back to
3719    /// the pre-replacement content. Use this for any operation the user
3720    /// expects to undo as a single step — e.g. external formatter output
3721    /// (`hjkl-mangler`) installed via the async [`crate::app::FormatWorker`].
3722    ///
3723    /// Like `push_undo`, this clears the redo stack (vim semantics: any
3724    /// new edit invalidates redo).
3725    pub fn set_content_undoable(&mut self, text: &str) {
3726        self.push_undo();
3727        let mut lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
3728        while lines.last().is_some_and(|l| l.is_empty()) {
3729            lines.pop();
3730        }
3731        if lines.is_empty() {
3732            lines.push(String::new());
3733        }
3734        let _ = lines;
3735        crate::types::BufferEdit::replace_all(&mut self.buffer, text);
3736        // Whole-buffer replace supersedes any queued ContentEdits.
3737        self.buffer.clear_pending_content_edits();
3738        self.buffer.set_pending_content_reset(true);
3739        self.mark_content_dirty();
3740    }
3741
3742    /// Drain the pending change log produced by buffer mutations.
3743    ///
3744    /// Returns a `Vec<EditOp>` covering edits applied since the last
3745    /// call. Empty when no edits ran. Pull-model, complementary to
3746    /// [`Editor::take_content_change`] which gives back the new full
3747    /// content.
3748    ///
3749    /// Mapping coverage:
3750    /// - InsertChar / InsertStr → exact `EditOp` with empty range +
3751    ///   replacement.
3752    /// - DeleteRange (`Char` kind) → exact range + empty replacement.
3753    /// - Replace → exact range + new replacement.
3754    /// - DeleteRange (`Line`/`Block`), JoinLines, SplitLines,
3755    ///   InsertBlock, DeleteBlockChunks → best-effort placeholder
3756    ///   covering the touched range. Hosts wanting per-cell deltas
3757    ///   should diff their own `lines()` snapshot.
3758    pub fn take_changes(&mut self) -> Vec<crate::types::Edit> {
3759        self.buffer.take_change_log()
3760    }
3761
3762    /// Read the engine's current settings as a SPEC
3763    /// [`crate::types::Options`].
3764    ///
3765    /// Bridges between the legacy [`Settings`] (which carries fewer
3766    /// fields than SPEC) and the planned 0.1.0 trait surface. Fields
3767    /// not present in `Settings` fall back to vim defaults (e.g.,
3768    /// `expandtab=false`, `wrapscan=true`, `timeout_len=1000ms`).
3769    /// Once trait extraction lands, this becomes the canonical config
3770    /// reader and `Settings` retires.
3771    pub fn current_options(&self) -> crate::types::Options {
3772        self.settings.to_options()
3773    }
3774
3775    /// Apply a SPEC [`crate::types::Options`] to the engine's settings.
3776    /// Only the fields backed by today's [`Settings`] take effect;
3777    /// remaining options become live once trait extraction wires them
3778    /// through.
3779    pub fn apply_options(&mut self, opts: &crate::types::Options) {
3780        self.settings.apply_options(opts);
3781    }
3782
3783    /// SPEC-typed highlights for `line`.
3784    ///
3785    /// Two emission modes:
3786    ///
3787    /// - **IncSearch**: the user is typing a `/` or `?` prompt and
3788    ///   `Editor::search_prompt` is `Some`. Live-preview matches of
3789    ///   the in-flight pattern surface as
3790    ///   [`crate::types::HighlightKind::IncSearch`].
3791    /// - **SearchMatch**: the prompt has been committed (or absent)
3792    ///   and the buffer's armed pattern is non-empty. Matches surface
3793    ///   as [`crate::types::HighlightKind::SearchMatch`].
3794    ///
3795    /// Selection / MatchParen / Syntax(id) variants land once the
3796    /// trait extraction routes the FSM's selection set + the host's
3797    /// syntax pipeline through the [`crate::types::Host`] trait.
3798    ///
3799    /// Returns an empty vec when there is nothing to highlight or
3800    /// `line` is out of bounds.
3801    pub fn highlights_for_line(&mut self, line: u32) -> Vec<crate::types::Highlight> {
3802        use crate::types::{Highlight, HighlightKind, Pos};
3803        let row = line as usize;
3804        if row >= buf_row_count(&self.buffer) {
3805            return Vec::new();
3806        }
3807
3808        // Live preview while the prompt is open beats the committed
3809        // pattern.
3810        if let Some(prompt) = self.search_prompt() {
3811            if prompt.text.is_empty() {
3812                return Vec::new();
3813            }
3814            use crate::search::{CaseMode, resolve_case_mode};
3815            let base =
3816                CaseMode::from_options(self.settings().ignore_case, self.settings().smartcase);
3817            let last_sub = self.last_substitute_replacement();
3818            let (stripped, mode) = resolve_case_mode(&prompt.text, base, &last_sub);
3819            let src = if mode == CaseMode::Insensitive {
3820                format!("(?i){stripped}")
3821            } else {
3822                stripped
3823            };
3824            let Ok(re) = regex::Regex::new(&src) else {
3825                return Vec::new();
3826            };
3827            let Some(haystack) = buf_line(&self.buffer, row) else {
3828                return Vec::new();
3829            };
3830            return re
3831                .find_iter(&haystack)
3832                .map(|m| Highlight {
3833                    range: Pos {
3834                        line,
3835                        col: m.start() as u32,
3836                    }..Pos {
3837                        line,
3838                        col: m.end() as u32,
3839                    },
3840                    kind: HighlightKind::IncSearch,
3841                })
3842                .collect();
3843        }
3844
3845        if self.search_state.pattern.is_none() {
3846            return Vec::new();
3847        }
3848        let dgen = crate::types::Query::dirty_gen(&self.buffer);
3849        crate::search::search_matches(&self.buffer, &mut self.search_state, dgen, row)
3850            .into_iter()
3851            .map(|(start, end)| Highlight {
3852                range: Pos {
3853                    line,
3854                    col: start as u32,
3855                }..Pos {
3856                    line,
3857                    col: end as u32,
3858                },
3859                kind: HighlightKind::SearchMatch,
3860            })
3861            .collect()
3862    }
3863
3864    /// Populate the per-row hlsearch match cache for rows `top..bottom`
3865    /// (no-op when no pattern). Lets the renderer read cached byte ranges
3866    /// instead of re-scanning each visible line every frame.
3867    pub fn populate_search_cache(&mut self, top: usize, bottom: usize) {
3868        if self.search_state.pattern.is_none() {
3869            return;
3870        }
3871        let dgen = crate::types::Query::dirty_gen(&self.buffer);
3872        let end = bottom.min(crate::types::Query::line_count(&self.buffer) as usize);
3873        for row in top..end {
3874            crate::search::warm_matches(&self.buffer, &mut self.search_state, dgen, row);
3875        }
3876    }
3877
3878    /// Build the engine's [`crate::types::RenderFrame`] for the
3879    /// current state. Hosts call this once per redraw and diff
3880    /// across frames.
3881    ///
3882    /// Coarse today — covers mode + cursor + cursor shape + viewport
3883    /// top + line count. SPEC-target fields (selections, highlights,
3884    /// command line, search prompt, status line) land once trait
3885    /// extraction routes them through `SelectionSet` and the
3886    /// `Highlight` pipeline.
3887    pub fn render_frame(&self) -> crate::types::RenderFrame {
3888        use crate::types::{CursorShape, RenderFrame, SnapshotMode};
3889        let (cursor_row, cursor_col) = self.cursor();
3890        // Coarse, not vim: render output must not depend on which discipline
3891        // is installed (#265). CoarseMode is a bijection with SnapshotMode.
3892        let (mode, shape) = match self.coarse_mode() {
3893            crate::CoarseMode::Normal => (SnapshotMode::Normal, CursorShape::Block),
3894            crate::CoarseMode::Insert => (SnapshotMode::Insert, CursorShape::Bar),
3895            crate::CoarseMode::Select => (SnapshotMode::Visual, CursorShape::Block),
3896            crate::CoarseMode::SelectLine => (SnapshotMode::VisualLine, CursorShape::Block),
3897            crate::CoarseMode::SelectBlock => (SnapshotMode::VisualBlock, CursorShape::Block),
3898        };
3899        RenderFrame {
3900            mode,
3901            cursor_row: cursor_row as u32,
3902            cursor_col: cursor_col as u32,
3903            cursor_shape: shape,
3904            viewport_top: self.host.viewport().top_row as u32,
3905            line_count: crate::types::Query::line_count(&self.buffer),
3906        }
3907    }
3908
3909    /// Capture the editor's coarse state into a serde-friendly
3910    /// [`crate::types::EditorSnapshot`].
3911    ///
3912    /// Today's snapshot covers mode, cursor, lines, viewport top.
3913    /// Registers, marks, jump list, undo tree, and full options arrive
3914    /// once phase 5 trait extraction lands the generic
3915    /// `Editor<B: View, H: Host>` constructor — this method's surface
3916    /// stays stable; only the snapshot's internal fields grow.
3917    ///
3918    /// Distinct from the internal `snapshot` used by undo (which
3919    /// returns `(Vec<String>, (usize, usize))`); host-facing
3920    /// persistence goes through this one.
3921    pub fn take_snapshot(&self) -> crate::types::EditorSnapshot {
3922        use crate::types::{EditorSnapshot, SnapshotMode};
3923        let mode = match self.coarse_mode() {
3924            crate::CoarseMode::Normal => SnapshotMode::Normal,
3925            crate::CoarseMode::Insert => SnapshotMode::Insert,
3926            crate::CoarseMode::Select => SnapshotMode::Visual,
3927            crate::CoarseMode::SelectLine => SnapshotMode::VisualLine,
3928            crate::CoarseMode::SelectBlock => SnapshotMode::VisualBlock,
3929        };
3930        let cursor = self.cursor();
3931        let cursor = (cursor.0 as u32, cursor.1 as u32);
3932        let rope = crate::types::Query::rope(&self.buffer);
3933        let lines: Vec<String> = (0..rope.len_lines())
3934            .map(|r| {
3935                let s = rope.line(r).to_string();
3936                if s.ends_with('\n') {
3937                    s[..s.len() - 1].to_string()
3938                } else {
3939                    s
3940                }
3941            })
3942            .collect();
3943        let viewport_top = self.host.viewport().top_row as u32;
3944        let marks = self
3945            .buffer
3946            .marks_cloned()
3947            .into_iter()
3948            .map(|(c, (r, col))| (c, (r as u32, col as u32)))
3949            .collect();
3950        let global_marks = self
3951            .global_marks
3952            .lock()
3953            .unwrap()
3954            .iter()
3955            .map(|(c, &(bid, r, col))| (*c, (bid, r as u32, col as u32)))
3956            .collect();
3957        EditorSnapshot {
3958            version: EditorSnapshot::VERSION,
3959            mode,
3960            cursor,
3961            lines,
3962            viewport_top,
3963            registers: self.registers.lock().unwrap().clone(),
3964            marks,
3965            global_marks,
3966        }
3967    }
3968
3969    /// Restore editor state from an [`EditorSnapshot`]. Returns
3970    /// [`crate::EngineError::SnapshotVersion`] if the snapshot's
3971    /// `version` doesn't match [`EditorSnapshot::VERSION`].
3972    ///
3973    /// Mode is best-effort: `SnapshotMode` only round-trips the
3974    /// status-line summary, not the full FSM state. Visual / Insert
3975    /// mode entry happens through synthetic key dispatch when needed.
3976    pub fn restore_snapshot(
3977        &mut self,
3978        snap: crate::types::EditorSnapshot,
3979    ) -> Result<(), crate::EngineError> {
3980        use crate::types::EditorSnapshot;
3981        if snap.version != EditorSnapshot::VERSION {
3982            return Err(crate::EngineError::SnapshotVersion(
3983                snap.version,
3984                EditorSnapshot::VERSION,
3985            ));
3986        }
3987        let text = snap.lines.join("\n");
3988        self.set_content(&text);
3989        self.jump_cursor(snap.cursor.0 as usize, snap.cursor.1 as usize);
3990        self.host.viewport_mut().top_row = snap.viewport_top as usize;
3991        *self.registers.lock().unwrap() = snap.registers;
3992        self.buffer.set_marks(
3993            snap.marks
3994                .into_iter()
3995                .map(|(c, (r, col))| (c, (r as usize, col as usize)))
3996                .collect(),
3997        );
3998        *self.global_marks.lock().unwrap() = snap
3999            .global_marks
4000            .into_iter()
4001            .map(|(c, (bid, r, col))| (c, (bid, r as usize, col as usize)))
4002            .collect();
4003        Ok(())
4004    }
4005
4006    /// Install `text` as the pending yank buffer so the next `p`/`P` pastes
4007    /// it. Linewise is inferred from a trailing newline, matching how `yy`/`dd`
4008    /// shape their payload.
4009    pub fn seed_yank(&mut self, text: String) {
4010        let linewise = text.ends_with('\n');
4011        self.yank_linewise = linewise;
4012        self.registers.lock().unwrap().unnamed = crate::registers::Slot {
4013            text,
4014            linewise,
4015            ..Default::default()
4016        };
4017    }
4018
4019    /// Scroll the viewport down by `rows`. The cursor stays on its
4020    /// absolute line (vim convention) unless the scroll would take it
4021    /// off-screen — in that case it's clamped to the first row still
4022    /// visible.
4023    pub fn scroll_down(&mut self, rows: i16) {
4024        self.scroll_viewport(rows);
4025    }
4026
4027    /// Scroll the viewport up by `rows`. Cursor stays unless it would
4028    /// fall off the bottom of the new viewport, then clamp to the
4029    /// bottom-most visible row.
4030    pub fn scroll_up(&mut self, rows: i16) {
4031        self.scroll_viewport(-rows);
4032    }
4033
4034    /// Scroll the viewport right by `cols` columns. Only the horizontal
4035    /// offset (`top_col`) moves — the cursor is NOT adjusted (matches
4036    /// vim's `zl` behaviour for horizontal scroll without wrap).
4037    pub fn scroll_right(&mut self, cols: i16) {
4038        let vp = self.host.viewport_mut();
4039        let cols_i = cols as isize;
4040        let new_top = (vp.top_col as isize + cols_i).max(0) as usize;
4041        vp.top_col = new_top;
4042    }
4043
4044    /// Scroll the viewport left by `cols` columns. Delegates to
4045    /// `scroll_right` with a negated argument so the floor-at-zero
4046    /// clamp is shared.
4047    pub fn scroll_left(&mut self, cols: i16) {
4048        self.scroll_right(-cols);
4049    }
4050
4051    /// Scroll the viewport so the cursor stays at least `scrolloff`
4052    /// rows from each edge. Replaces the bare
4053    /// `View::ensure_cursor_visible` call at end-of-step so motions
4054    /// don't park the cursor on the very last visible row.
4055    pub fn ensure_cursor_in_scrolloff(&mut self) {
4056        let height = self.viewport_height.load(Ordering::Relaxed) as usize;
4057        if height == 0 {
4058            // 0.0.42 (Patch C-δ.7): viewport math lifted onto engine
4059            // free fns over `B: Query [+ Cursor]` + `&dyn FoldProvider`.
4060            // Disjoint-field borrow split: `self.buffer` (immutable via
4061            // `folds` snapshot + cursor) and `self.host` (mutable
4062            // viewport ref) live on distinct struct fields, so one
4063            // statement satisfies the borrow checker.
4064            let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
4065            crate::viewport_math::ensure_cursor_visible(
4066                &self.buffer,
4067                &folds,
4068                self.host.viewport_mut(),
4069            );
4070            return;
4071        }
4072        // Cap margin at (height - 1) / 2 so the upper + lower bands
4073        // can't overlap on tiny windows (margin=5 + height=10 would
4074        // otherwise produce contradictory clamp ranges).
4075        let margin = self.settings.scrolloff.min(height.saturating_sub(1) / 2);
4076        // Screen rows ≠ doc rows only under soft-wrap (a doc row spans many
4077        // screen lines) or folds (a closed fold collapses many doc rows to
4078        // one); doc-row margin math drifts in those cases. Dispatch:
4079        //   • wrap            → the incremental screen-row walk.
4080        //   • folds, no wrap  → the O(height) fold-aware clamp below.
4081        //   • neither         → the fast O(1) doc-row math (every plain j/k/G).
4082        let wrapped = !matches!(self.host.viewport().wrap, hjkl_buffer::Wrap::None);
4083        if wrapped {
4084            self.ensure_scrolloff_vertical(height, margin);
4085            return;
4086        }
4087        if self.buffer.has_folds() {
4088            self.ensure_scrolloff_folds_nowrap(height, margin);
4089            // Column-side (horizontal) scroll only — keep the fold-aware
4090            // top_row by snapshotting it across `ensure_visible`.
4091            let cursor = buf_cursor_pos(&self.buffer);
4092            let saved_top = self.host.viewport().top_row;
4093            self.host.viewport_mut().ensure_visible(cursor);
4094            self.host.viewport_mut().top_row = saved_top;
4095            return;
4096        }
4097        let cursor_row = buf_cursor_row(&self.buffer);
4098        let last_row = buf_row_count(&self.buffer).saturating_sub(1);
4099        let v = self.host.viewport_mut();
4100        // Top edge: cursor_row should sit at >= top_row + margin.
4101        if cursor_row < v.top_row + margin {
4102            v.top_row = cursor_row.saturating_sub(margin);
4103        }
4104        // Bottom edge: cursor_row should sit at <= top_row + height - 1 - margin.
4105        let max_bottom = height.saturating_sub(1).saturating_sub(margin);
4106        if cursor_row > v.top_row + max_bottom {
4107            v.top_row = cursor_row.saturating_sub(max_bottom);
4108        }
4109        // Clamp top_row so we never scroll past the buffer's bottom.
4110        let max_top = last_row.saturating_sub(height.saturating_sub(1));
4111        if v.top_row > max_top {
4112            v.top_row = max_top;
4113        }
4114        // Column-side scroll (vim default `sidescrolloff = 0`).
4115        let cursor = buf_cursor_pos(&self.buffer);
4116        self.host.viewport_mut().ensure_visible(cursor);
4117    }
4118
4119    /// Fold-aware vertical scrolloff for `Wrap::None`, in **O(height)**.
4120    ///
4121    /// A closed fold collapses its body to one screen row, so the cursor's
4122    /// screen row is the count of *visible* rows above it — not the doc-row
4123    /// delta. Instead of re-walking that count on every candidate `top_row`
4124    /// (the incremental [`Self::ensure_scrolloff_vertical`], O(n²) on a big
4125    /// jump like `G` over a fold-heavy file), compute the valid `top_row`
4126    /// window directly: at most `height-1-margin` visible rows may sit above
4127    /// the cursor (bottom edge) and at least `margin` (top edge). Walk those
4128    /// two bounds up from the cursor via `prev_visible_row`, clamp the current
4129    /// `top_row` into the window, then clamp to `max_top_for_height` so the
4130    /// buffer's bottom never leaves blank rows. Each walk is bounded by
4131    /// `height`, so the whole thing is O(height) regardless of jump distance.
4132    fn ensure_scrolloff_folds_nowrap(&mut self, height: usize, margin: usize) {
4133        let cursor_row = buf_cursor_row(&self.buffer);
4134        let max_csr = height.saturating_sub(1).saturating_sub(margin);
4135        // `top_lo`: the row `max_csr` visible rows above the cursor — `top_row`
4136        // must be >= this to keep the cursor within the bottom margin.
4137        let mut top_lo = cursor_row;
4138        for _ in 0..max_csr {
4139            match self.buffer.prev_visible_row(top_lo) {
4140                Some(p) => top_lo = p,
4141                None => break,
4142            }
4143        }
4144        // `top_hi`: the row `margin` visible rows above the cursor — `top_row`
4145        // must be <= this to keep the cursor below the top margin.
4146        let mut top_hi = cursor_row;
4147        for _ in 0..margin {
4148            match self.buffer.prev_visible_row(top_hi) {
4149                Some(p) => top_hi = p,
4150                None => break,
4151            }
4152        }
4153        // `max_csr >= margin` (margin is capped at (height-1)/2), so
4154        // `top_lo <= top_hi` and the clamp range is well-formed.
4155        let cur = self.host.viewport().top_row;
4156        let mut new_top = cur.clamp(top_lo, top_hi);
4157        let max_top = {
4158            let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
4159            crate::viewport_math::max_top_for_height(
4160                &self.buffer,
4161                &folds,
4162                self.host.viewport(),
4163                height,
4164            )
4165        };
4166        if new_top > max_top {
4167            new_top = max_top;
4168        }
4169        self.host.viewport_mut().top_row = new_top;
4170    }
4171
4172    /// Screen-row-aware vertical scrolloff. Walks `top_row` one visible
4173    /// doc row at a time so the cursor's *screen* row stays inside
4174    /// `[margin, height - 1 - margin]`, then clamps `top_row` so the
4175    /// buffer's bottom never leaves blank rows below it.
4176    ///
4177    /// Correct under BOTH soft-wrap (a doc row spans many screen lines)
4178    /// and folds (a closed fold collapses many doc rows to one screen
4179    /// row): [`crate::viewport_math::cursor_screen_row_from`] counts
4180    /// visible/wrapped screen rows, so doc-row arithmetic can't drift the
4181    /// margin around a fold. Horizontal (column) scroll is the caller's
4182    /// job — this only moves `top_row`.
4183    fn ensure_scrolloff_vertical(&mut self, height: usize, margin: usize) {
4184        let cursor_row = buf_cursor_row(&self.buffer);
4185        // Step 1 — cursor above viewport: snap top to cursor row,
4186        // then we'll fix up the margin below.
4187        if cursor_row < self.host.viewport().top_row {
4188            let v = self.host.viewport_mut();
4189            v.top_row = cursor_row;
4190            v.top_col = 0;
4191        }
4192        // Step 2 — push top forward until cursor's screen row is
4193        // within the bottom margin (`csr <= height - 1 - margin`).
4194        // 0.0.33 (Patch C-γ): fold-iteration goes through the
4195        // [`crate::types::FoldProvider`] surface via
4196        // [`crate::buffer_impl::BufferFoldProvider`]. 0.0.34 (Patch
4197        // C-δ.1): `cursor_screen_row` / `max_top_for_height` now take
4198        // a `&Viewport` parameter; the host owns the viewport, so the
4199        // disjoint `(self.host, self.buffer)` borrows split cleanly.
4200        let max_csr = height.saturating_sub(1).saturating_sub(margin);
4201        loop {
4202            let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
4203            let top = self.host.viewport().top_row;
4204            let csr = crate::viewport_math::cursor_screen_row_from(
4205                &self.buffer,
4206                &folds,
4207                self.host.viewport(),
4208                top,
4209            )
4210            .unwrap_or(0);
4211            if csr <= max_csr {
4212                break;
4213            }
4214            let row_count = buf_row_count(&self.buffer);
4215            let next = {
4216                let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
4217                <crate::buffer_impl::BufferFoldProvider<'_> as crate::types::FoldProvider>::next_visible_row(&folds, top, row_count)
4218            };
4219            let Some(next) = next else {
4220                break;
4221            };
4222            // Don't walk past the cursor's row.
4223            if next > cursor_row {
4224                self.host.viewport_mut().top_row = cursor_row;
4225                break;
4226            }
4227            self.host.viewport_mut().top_row = next;
4228        }
4229        // Step 3 — pull top backward until cursor's screen row is
4230        // past the top margin (`csr >= margin`).
4231        loop {
4232            let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
4233            let top = self.host.viewport().top_row;
4234            let csr = crate::viewport_math::cursor_screen_row_from(
4235                &self.buffer,
4236                &folds,
4237                self.host.viewport(),
4238                top,
4239            )
4240            .unwrap_or(0);
4241            if csr >= margin {
4242                break;
4243            }
4244            let prev = {
4245                let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
4246                <crate::buffer_impl::BufferFoldProvider<'_> as crate::types::FoldProvider>::prev_visible_row(&folds, top)
4247            };
4248            let Some(prev) = prev else {
4249                break;
4250            };
4251            self.host.viewport_mut().top_row = prev;
4252        }
4253        // Step 4 — clamp top so the buffer's bottom doesn't leave
4254        // blank rows below it. `max_top_for_height` walks segments
4255        // backward from the last row until it accumulates `height`
4256        // screen rows.
4257        let max_top = {
4258            let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
4259            crate::viewport_math::max_top_for_height(
4260                &self.buffer,
4261                &folds,
4262                self.host.viewport(),
4263                height,
4264            )
4265        };
4266        if self.host.viewport().top_row > max_top {
4267            self.host.viewport_mut().top_row = max_top;
4268        }
4269        self.host.viewport_mut().top_col = 0;
4270    }
4271
4272    fn scroll_viewport(&mut self, delta: i16) {
4273        if delta == 0 {
4274            return;
4275        }
4276        // Bump the host viewport's top within bounds.
4277        let total_rows = buf_row_count(&self.buffer) as isize;
4278        let height = self.viewport_height.load(Ordering::Relaxed) as usize;
4279        let cur_top = self.host.viewport().top_row as isize;
4280        let new_top = (cur_top + delta as isize)
4281            .max(0)
4282            .min((total_rows - 1).max(0)) as usize;
4283        self.host.viewport_mut().top_row = new_top;
4284        // Mirror to textarea so its viewport reads (still consumed by
4285        // a couple of helpers) stay accurate.
4286        let _ = cur_top;
4287        if height == 0 {
4288            return;
4289        }
4290        // Apply scrolloff: keep the cursor at least scrolloff rows
4291        // from the visible viewport edges.
4292        let (cursor_row, cursor_col) = buf_cursor_rc(&self.buffer);
4293        let margin = self.settings.scrolloff.min(height / 2);
4294        let min_row = new_top + margin;
4295        let max_row = new_top + height.saturating_sub(1).saturating_sub(margin);
4296        let target_row = cursor_row.clamp(min_row, max_row.max(min_row));
4297        if target_row != cursor_row {
4298            let line_len = buf_line(&self.buffer, target_row).map_or(0, |l| l.chars().count());
4299            let target_col = cursor_col.min(line_len.saturating_sub(1));
4300            buf_set_cursor_rc(&mut self.buffer, target_row, target_col);
4301        }
4302    }
4303
4304    pub fn goto_line(&mut self, line: usize) {
4305        let row = line.saturating_sub(1);
4306        let max = buf_row_count(&self.buffer).saturating_sub(1);
4307        let target = row.min(max);
4308        // If the target row is hidden inside one or more closed folds, open
4309        // every fold that collapses it so the landing line is actually
4310        // visible — a jump to an unseen row is useless. `reveal_row` opens
4311        // all hiding folds (outer + nested) in one pass; `open_fold_at` /
4312        // `FoldOp::OpenAt` can't, because they only act on the first fold
4313        // containing the row and so can never reach a nested inner fold.
4314        self.buffer.reveal_row(target);
4315        buf_set_cursor_rc(&mut self.buffer, target, 0);
4316        // Vim: `:N` / `+N` jump scrolls the viewport too — without this
4317        // the cursor lands off-screen and the user has to scroll
4318        // manually to see it.
4319        self.ensure_cursor_in_scrolloff();
4320    }
4321
4322    /// Scroll so the cursor row lands at the given viewport position:
4323    /// `Center` → middle row, `Top` → first row, `Bottom` → last row.
4324    /// Cursor stays on its absolute line; only the viewport moves.
4325    pub fn scroll_cursor_to(&mut self, pos: CursorScrollTarget) {
4326        let height = self.viewport_height.load(Ordering::Relaxed) as usize;
4327        if height == 0 {
4328            return;
4329        }
4330        let cur_row = buf_cursor_row(&self.buffer);
4331        let cur_top = self.host.viewport().top_row;
4332        // Scrolloff awareness: `zt` lands the cursor at the top edge
4333        // of the viable area (top + margin), `zb` at the bottom edge
4334        // (top + height - 1 - margin). Match the cap used by
4335        // `ensure_cursor_in_scrolloff` so contradictory bounds are
4336        // impossible on tiny viewports.
4337        let margin = self.settings.scrolloff.min(height.saturating_sub(1) / 2);
4338        let new_top = match pos {
4339            CursorScrollTarget::Center => cur_row.saturating_sub(height / 2),
4340            CursorScrollTarget::Top => cur_row.saturating_sub(margin),
4341            CursorScrollTarget::Bottom => {
4342                cur_row.saturating_sub(height.saturating_sub(1).saturating_sub(margin))
4343            }
4344        };
4345        if new_top == cur_top {
4346            return;
4347        }
4348        self.host.viewport_mut().top_row = new_top;
4349    }
4350
4351    /// Jump the cursor to the given 1-based line/column, clamped to the document.
4352    pub fn jump_to(&mut self, line: usize, col: usize) {
4353        let r = line.saturating_sub(1);
4354        let max_row = buf_row_count(&self.buffer).saturating_sub(1);
4355        let r = r.min(max_row);
4356        let line_len = buf_line(&self.buffer, r).map_or(0, |l| l.chars().count());
4357        let c = col.saturating_sub(1).min(line_len);
4358        buf_set_cursor_rc(&mut self.buffer, r, c);
4359    }
4360
4361    // ── Host-agnostic doc-coord mouse primitives (Phase 1 of issue #114) ─────
4362    //
4363    // These primitives operate on document (row, col) coordinates that the HOST
4364    // computes from its own layout knowledge (cell geometry for the TUI host,
4365    // pixel geometry for the future GUI host). The engine has no u16 terminal
4366    // assumption here — it just moves the cursor in doc-space.
4367
4368    /// Set the cursor to the given doc-space `(row, col)`, clamped to the
4369    /// document bounds. Hosts use this for programmatic cursor placement and
4370    /// as the building block for the mouse-click path.
4371    ///
4372    /// `col` may equal `line.chars().count()` (Insert-mode "one past end"
4373    /// position); values beyond that are clamped to `char_count`.
4374    pub fn set_cursor_doc(&mut self, row: usize, col: usize) {
4375        let max_row = buf_row_count(&self.buffer).saturating_sub(1);
4376        let r = row.min(max_row);
4377        let line_len = buf_line(&self.buffer, r).map_or(0, |l| l.chars().count());
4378        let c = col.min(line_len);
4379        buf_set_cursor_rc(&mut self.buffer, r, c);
4380    }
4381
4382    /// Extend an in-progress mouse drag to doc-space `(row, col)`.
4383    ///
4384    /// Moves the live cursor; the Visual anchor stays where
4385    /// [`Editor::mouse_begin_drag`] set it. Call after the host has
4386    /// translated the drag position to doc coordinates.
4387    pub fn mouse_extend_drag_doc(&mut self, row: usize, col: usize) {
4388        self.set_cursor_doc(row, col);
4389    }
4390
4391    pub fn insert_str(&mut self, text: &str) {
4392        let pos = crate::types::Cursor::cursor(&self.buffer);
4393        crate::types::BufferEdit::insert_at(&mut self.buffer, pos, text);
4394        self.push_buffer_content_to_textarea();
4395        self.mark_content_dirty();
4396    }
4397
4398    pub fn accept_completion(&mut self, completion: &str) {
4399        use crate::types::{BufferEdit, Cursor as CursorTrait, Pos};
4400        let cursor_pos = CursorTrait::cursor(&self.buffer);
4401        let cursor_row = cursor_pos.line as usize;
4402        let cursor_col = cursor_pos.col as usize;
4403        let line = buf_line(&self.buffer, cursor_row).unwrap_or_default();
4404        let chars: Vec<char> = line.chars().collect();
4405        let prefix_len = chars[..cursor_col.min(chars.len())]
4406            .iter()
4407            .rev()
4408            .take_while(|c| c.is_alphanumeric() || **c == '_')
4409            .count();
4410        if prefix_len > 0 {
4411            let start = Pos {
4412                line: cursor_row as u32,
4413                col: (cursor_col - prefix_len) as u32,
4414            };
4415            BufferEdit::delete_range(&mut self.buffer, start..cursor_pos);
4416        }
4417        let cursor = CursorTrait::cursor(&self.buffer);
4418        BufferEdit::insert_at(&mut self.buffer, cursor, completion);
4419        self.push_buffer_content_to_textarea();
4420        self.mark_content_dirty();
4421    }
4422
4423    /// Capture the buffer state for undo / redo.  Uses
4424    /// [`Query::content_joined`], which the `View` impl caches as an
4425    /// `Arc<String>` against `dirty_gen` — so when LSP / git / syntax
4426    /// already joined this generation, the snapshot is an `Arc::clone`
4427    /// (one ptr bump). Previously this cloned every line into a
4428    /// `Vec<String>` (162 k allocations on a 162 k-row buffer) and the
4429    /// matching `restore` re-joined them — samply showed it at ~9 % of
4430    /// CPU on a big-paste session.
4431    pub(super) fn snapshot(&self) -> (ropey::Rope, (usize, usize)) {
4432        use crate::types::Query;
4433        let rc = buf_cursor_rc(&self.buffer);
4434        (Query::rope(&self.buffer), rc)
4435    }
4436
4437    /// Snapshot the buffer-scoped "edit coherence" state alongside a rope
4438    /// snapshot, so undo/redo can restore marks/jumplist/changelist, not
4439    /// just text (audit-r2 fix 2).
4440    ///
4441    /// Called at all three `UndoEntry` construction sites
4442    /// (`push_undo_at`, `undo_core`, `redo_core`) with the LIVE state at
4443    /// push time — never the popped entry's own snapshot, since the entry
4444    /// being pushed describes "the other side" of the history walk (e.g.
4445    /// `undo_core`'s redo-push needs the CURRENT, post-edit marks so a
4446    /// later redo restores them, not the pre-edit marks it's about to
4447    /// pop).
4448    pub(super) fn snapshot_marks(&self) -> hjkl_buffer::MarkSnapshot {
4449        let cur_bid = self.current_buffer_id;
4450        let global_marks = self
4451            .global_marks
4452            .lock()
4453            .unwrap()
4454            .iter()
4455            .filter(|(_, (bid, _, _))| *bid == cur_bid)
4456            .map(|(c, (_, row, col))| (*c, (*row, *col)))
4457            .collect();
4458        let bank = self.change_bank.lock().unwrap();
4459        hjkl_buffer::MarkSnapshot {
4460            local_marks: self.buffer.marks_cloned(),
4461            jump_back: self.jump_back.clone(),
4462            jump_fwd: self.jump_fwd.clone(),
4463            change_last_edit: bank.last_edit,
4464            change_list: bank.list.clone(),
4465            change_cursor: bank.cursor,
4466            global_marks,
4467        }
4468    }
4469
4470    /// Restore the buffer-scoped state captured by [`Editor::snapshot_marks`]
4471    /// — the undo/redo counterpart to `restore_rope`/`restore_text`.
4472    ///
4473    /// Only entries belonging to THIS buffer (`current_buffer_id`) are
4474    /// touched in the session-global `global_marks` map: other buffers'
4475    /// global marks are left completely alone. Local marks and the
4476    /// changelist bank are already per-buffer (shared via `Arc` across
4477    /// windows on the same buffer, same as the text), so restoring them
4478    /// here is visible to every window on this buffer, matching vim.
4479    pub(super) fn restore_marks(&mut self, snap: &hjkl_buffer::MarkSnapshot) {
4480        self.buffer.set_marks(snap.local_marks.clone());
4481        self.jump_back.clone_from(&snap.jump_back);
4482        self.jump_fwd.clone_from(&snap.jump_fwd);
4483        {
4484            let mut bank = self.change_bank.lock().unwrap();
4485            bank.last_edit = snap.change_last_edit;
4486            bank.list.clone_from(&snap.change_list);
4487            bank.cursor = snap.change_cursor;
4488        }
4489        let cur_bid = self.current_buffer_id;
4490        let mut global_marks = self.global_marks.lock().unwrap();
4491        global_marks.retain(|_, (bid, _, _)| *bid != cur_bid);
4492        for (c, (row, col)) in snap.global_marks.iter() {
4493            global_marks.insert(*c, (cur_bid, *row, *col));
4494        }
4495    }
4496
4497    // ── Undo / redo (discipline-agnostic, #265) ──────────────────────────────
4498    //
4499    // The rope-level work is generic — every discipline undoes. The only
4500    // discipline-specific part is what state the editor is left in afterwards,
4501    // which goes through `DisciplineState::reset_to_idle` plus a coarse cursor
4502    // clamp, so the engine never names vim.
4503
4504    /// Rope-level undo, then return the discipline to idle.
4505    ///
4506    /// Drives the undo arena tree: [`View::undo_step`](hjkl_buffer::View) writes
4507    /// the live state into the node we leave (that node becomes the redo target)
4508    /// and returns the parent snapshot to restore. Behaviourally identical to
4509    /// the old pop-undo / push-redo dance — the moved-across node inherits the
4510    /// destination's timestamp, exactly as the old redo entry did.
4511    fn undo_core(&mut self) {
4512        if !self.buffer.undo_stack_is_empty() {
4513            let (cur_rope, cur_cursor) = self.snapshot();
4514            let cur_marks = self.snapshot_marks();
4515            if let Some(entry) = self.buffer.undo_step(cur_rope, cur_cursor, cur_marks) {
4516                self.restore_rope(&entry.rope, entry.cursor);
4517                self.restore_marks(&entry.marks);
4518            }
4519        }
4520        self.settle_after_history_jump();
4521    }
4522
4523    /// Rope-level redo, then return the discipline to idle.
4524    fn redo_core(&mut self) {
4525        if !self.buffer.redo_stack_is_empty() {
4526            let (cur_rope, cur_cursor) = self.snapshot();
4527            let cur_marks = self.snapshot_marks();
4528            let before = cur_rope.clone();
4529            if let Some(entry) = self.buffer.redo_step(cur_rope, cur_cursor, cur_marks) {
4530                self.cap_undo();
4531                self.restore_rope(&entry.rope, entry.cursor);
4532                self.restore_marks(&entry.marks);
4533                // Park the cursor at the START of the reapplied change rather
4534                // than the end-of-insert position stored in the redo snapshot
4535                // (vim parity). Recompute from the first differing character.
4536                let after = crate::types::Query::rope(&self.buffer);
4537                if let Some((row, col)) = first_diff_pos(&before, &after) {
4538                    buf_set_cursor_rc(&mut self.buffer, row, col);
4539                }
4540            }
4541        }
4542        self.settle_after_history_jump();
4543    }
4544
4545    /// Leave the editor in a known resting state after jumping through history
4546    /// (undo / redo) or after a `:!` filter rewrote the buffer.
4547    ///
4548    /// Asks the installed discipline to put its *mode* back to idle — without
4549    /// discarding an open insert session, which vscode-mode undo depends on —
4550    /// then clamps the cursor to a valid column.
4551    pub(crate) fn settle_after_history_jump(&mut self) {
4552        self.discipline.reset_mode_after_history();
4553        // Undo / redo restore a whole snapshot: the secondary selections were
4554        // computed against a document that no longer exists, and nothing tracked
4555        // them across the rewind. Drop them rather than leave carets pointing at
4556        // text that moved — the same "drop, never guess" rule `selection_shift`
4557        // applies to a single untrackable edit.
4558        self.extra_selections.clear();
4559        // Unconditional clamp: the restored cursor came from a snapshot that may
4560        // have been taken mid-insert and can sit one past the last valid column.
4561        let (row, col) = self.cursor();
4562        let max_col = buf_line_chars(&self.buffer, row).saturating_sub(1);
4563        if col > max_col {
4564            buf_set_cursor_rc(&mut self.buffer, row, max_col);
4565        }
4566        // audit-r2 fix 3(a): vim's 'foldopen' option includes "undo" — an
4567        // undo/redo that lands the cursor inside a closed fold's body must
4568        // reveal it, not strand the cursor on a hidden row with no way to
4569        // see what it's sitting on. `reveal_row` already opens every fold
4570        // (at any nesting depth) that hides a row in one pass; loop it
4571        // defensively — bounded by the fold count — so a row that's
4572        // somehow still hidden after one reveal (e.g. a future change to
4573        // `reveal_row`'s semantics) keeps getting opened rather than
4574        // silently left stranded.
4575        let row = buf_cursor_row(&self.buffer);
4576        let max_iters = self.buffer.with_folds(<[hjkl_buffer::Fold]>::len) + 1;
4577        for _ in 0..max_iters {
4578            if !self.buffer.is_row_hidden(row) {
4579                break;
4580            }
4581            if !self.buffer.reveal_row(row) {
4582                break;
4583            }
4584        }
4585    }
4586
4587    /// Walk one step back through the undo history. Equivalent to the
4588    /// user pressing `u` in normal mode. Drains the most recent undo
4589    /// entry and pushes it onto the redo stack.
4590    pub fn undo(&mut self) {
4591        self.undo_core();
4592    }
4593
4594    /// Walk one step forward through the redo history. Equivalent to
4595    /// `<C-r>` in normal mode.
4596    pub fn redo(&mut self) {
4597        self.redo_core();
4598    }
4599
4600    /// `[count]u` — undo `n` times, BRANCH-LOCAL (each step walks to the parent
4601    /// on the current branch, not the seq order). This is what `u` binds to;
4602    /// `g-`/`:earlier` use the tree-wide [`earlier_by_steps`](Self::earlier_by_steps)
4603    /// seq walk instead. Stops at the branch root.
4604    pub fn undo_by_steps(&mut self, n: usize) -> usize {
4605        let mut count = 0;
4606        for _ in 0..n {
4607            if self.buffer.undo_stack_is_empty() {
4608                break;
4609            }
4610            self.undo_core();
4611            count += 1;
4612        }
4613        count
4614    }
4615
4616    /// `[count]<C-r>` — redo `n` times, BRANCH-LOCAL (each step follows
4617    /// `last_child`). Counterpart to [`undo_by_steps`](Self::undo_by_steps).
4618    pub fn redo_by_steps(&mut self, n: usize) -> usize {
4619        let mut count = 0;
4620        for _ in 0..n {
4621            if self.buffer.redo_stack_is_empty() {
4622                break;
4623            }
4624            self.redo_core();
4625            count += 1;
4626        }
4627        count
4628    }
4629
4630    /// `U` (`:h U`): restore the line where the latest change was made to
4631    /// its state before that run of changes began — NOT necessarily the
4632    /// line the cursor is currently on (moving the cursor away without
4633    /// editing doesn't retarget `U`). A no-op when nothing has changed
4634    /// on the tracked line relative to the stored snapshot (either
4635    /// nothing has been edited yet, or a prior `U` already restored it).
4636    ///
4637    /// `U` is itself a change: it pushes one undo entry (so a plain `u`
4638    /// right after `U` undoes the restore), and it swaps the stored
4639    /// snapshot to the text it just replaced, so a second `U` toggles
4640    /// back and re-applies the changes the first one undid.
4641    pub fn undo_line(&mut self) {
4642        let target = self.change_bank.lock().unwrap().u_line.clone();
4643        let Some((row, snapshot)) = target else {
4644            return;
4645        };
4646        if row >= buf_row_count(&self.buffer) {
4647            return;
4648        }
4649        let current = buf_line(&self.buffer, row).unwrap_or_default();
4650        if current == snapshot {
4651            return;
4652        }
4653        self.push_undo();
4654        let line_chars = buf_line_chars(&self.buffer, row);
4655        self.suppress_u_line_track = true;
4656        self.mutate_edit(hjkl_buffer::Edit::DeleteRange {
4657            start: hjkl_buffer::Position::new(row, 0),
4658            end: hjkl_buffer::Position::new(row, line_chars),
4659            kind: hjkl_buffer::MotionKind::Char,
4660        });
4661        self.mutate_edit(hjkl_buffer::Edit::InsertStr {
4662            at: hjkl_buffer::Position::new(row, 0),
4663            text: snapshot,
4664        });
4665        self.suppress_u_line_track = false;
4666        self.change_bank.lock().unwrap().u_line = Some((row, current));
4667        buf_set_cursor_rc(&mut self.buffer, row, 0);
4668    }
4669
4670    /// One `g-` step: restore the next-lower-`seq` state anywhere in the undo
4671    /// tree. Branch-crossing counterpart of
4672    /// [`undo_core`](Self::undo_core); restores the destination snapshot exactly
4673    /// like an undo. Returns `false` when already at the lowest state.
4674    fn seq_earlier_core(&mut self) -> bool {
4675        let (cur_rope, cur_cursor) = self.snapshot();
4676        let cur_marks = self.snapshot_marks();
4677        if let Some(entry) = self
4678            .buffer
4679            .seq_earlier_step(cur_rope, cur_cursor, cur_marks)
4680        {
4681            self.restore_rope(&entry.rope, entry.cursor);
4682            self.restore_marks(&entry.marks);
4683            self.settle_after_history_jump();
4684            true
4685        } else {
4686            false
4687        }
4688    }
4689
4690    /// One `g+` step: restore the next-higher-`seq` state anywhere in the undo
4691    /// tree. Branch-crossing counterpart of [`redo_core`](Self::redo_core),
4692    /// including its vim-parity cursor-park at the start of the reapplied
4693    /// change. Returns `false` when already at the highest state.
4694    fn seq_later_core(&mut self) -> bool {
4695        let (cur_rope, cur_cursor) = self.snapshot();
4696        let cur_marks = self.snapshot_marks();
4697        let before = cur_rope.clone();
4698        if let Some(entry) = self.buffer.seq_later_step(cur_rope, cur_cursor, cur_marks) {
4699            self.cap_undo();
4700            self.restore_rope(&entry.rope, entry.cursor);
4701            self.restore_marks(&entry.marks);
4702            let after = crate::types::Query::rope(&self.buffer);
4703            if let Some((row, col)) = first_diff_pos(&before, &after) {
4704                buf_set_cursor_rc(&mut self.buffer, row, col);
4705            }
4706            self.settle_after_history_jump();
4707            true
4708        } else {
4709            false
4710        }
4711    }
4712
4713    /// `g-` / `:earlier N` — travel `n` states back through the undo TREE by
4714    /// `seq` (crossing branches), not the branch-local `u` path. Returns the
4715    /// number of steps actually applied (clamped at the oldest state).
4716    pub fn earlier_by_steps(&mut self, n: usize) -> usize {
4717        let mut count = 0;
4718        for _ in 0..n {
4719            if self.seq_earlier_core() {
4720                count += 1;
4721            } else {
4722                break;
4723            }
4724        }
4725        count
4726    }
4727
4728    /// `g+` / `:later N` — travel `n` states forward through the undo TREE by
4729    /// `seq`. Returns the number of steps actually applied (clamped at the
4730    /// newest state).
4731    pub fn later_by_steps(&mut self, n: usize) -> usize {
4732        let mut count = 0;
4733        for _ in 0..n {
4734            if self.seq_later_core() {
4735                count += 1;
4736            } else {
4737                break;
4738            }
4739        }
4740        count
4741    }
4742
4743    /// Travel back through the tree (by `seq`) while the next-older state's
4744    /// timestamp is strictly greater than `target`; stop once it is at/below.
4745    /// Returns the number of steps applied.
4746    ///
4747    /// Vim `:earlier Ns` semantics: `target = SystemTime::now() - N seconds`.
4748    /// The walk is tree-wide (same seq order as `g-`), so it crosses branches.
4749    pub fn earlier_by_time(&mut self, target: SystemTime) -> usize {
4750        let mut count = 0;
4751        loop {
4752            match self.buffer.seq_earlier_timestamp() {
4753                None => break,
4754                Some(ts) => {
4755                    if ts <= target {
4756                        break;
4757                    }
4758                }
4759            }
4760            if self.seq_earlier_core() {
4761                count += 1;
4762            } else {
4763                break;
4764            }
4765        }
4766        count
4767    }
4768
4769    /// Travel forward through the tree (by `seq`) while the next-newer state's
4770    /// timestamp is at/below `target`. Returns the number of steps applied.
4771    ///
4772    /// Vim `:later Ns` semantics: `target = current_state_time + N seconds`.
4773    pub fn later_by_time(&mut self, target: SystemTime) -> usize {
4774        let mut count = 0;
4775        loop {
4776            match self.buffer.seq_later_timestamp() {
4777                None => break,
4778                Some(ts) => {
4779                    if ts > target {
4780                        break;
4781                    }
4782                }
4783            }
4784            if self.seq_later_core() {
4785                count += 1;
4786            } else {
4787                break;
4788            }
4789        }
4790        count
4791    }
4792
4793    /// Undo-tree leaves for `:undolist`: `(seq, changes/depth, timestamp,
4794    /// is_current)` sorted by `seq`. Like nvim, `:undolist` shows only branch
4795    /// leaves, not every intermediate node.
4796    pub fn undo_leaves(&self) -> Vec<(u64, usize, SystemTime, bool)> {
4797        self.buffer.undo_leaves()
4798    }
4799
4800    /// Snapshot current buffer state onto the undo stack and clear
4801    /// the redo stack. Bounded by `settings.undo_levels` — older
4802    /// entries pruned. Call before any group of buffer mutations the
4803    /// user might want to undo as a single step.
4804    pub fn push_undo(&mut self) {
4805        self.push_undo_at(SystemTime::now());
4806    }
4807
4808    /// Open an undo group. Every [`push_undo`](Self::push_undo) until the
4809    /// returned guard drops collapses into a single undo step. Re-entrant
4810    /// (depth-counted): nested `undo_group()` calls just nest, and only the
4811    /// OUTERMOST close commits — so a `:g` whose sub-command is itself grouped
4812    /// still yields one undo step. A group that mutates nothing leaves zero
4813    /// undo entries. Closing on `Drop` makes it early-return / panic safe.
4814    ///
4815    /// The returned guard is `#[must_use]`;
4816    /// bind it (`let _g = …`) so it lives for the whole grouped operation.
4817    pub fn undo_group(&mut self) -> UndoGroup {
4818        let content = self.buffer.content_arc();
4819        content.lock().unwrap().undo_group_enter();
4820        UndoGroup { content }
4821    }
4822
4823    /// Like [`push_undo`] but uses a caller-supplied timestamp. Used by
4824    /// tests that need deterministic time values without `sleep`.
4825    #[doc(hidden)]
4826    pub fn push_undo_at(&mut self, timestamp: SystemTime) {
4827        // Inside an open undo group, coalesce: only the FIRST mutating
4828        // push_undo in the outermost group takes a snapshot; every later one
4829        // is suppressed (no create-then-pop). At depth 0 (`undo_group_active`
4830        // is false) the `&&` short-circuits before `undo_group_arm`, so no
4831        // group state is touched and the path below is byte-identical to the
4832        // pre-group behavior.
4833        if self.buffer.undo_group_active() && !self.buffer.undo_group_arm() {
4834            return;
4835        }
4836        let (rope, cursor) = self.snapshot();
4837        let marks = self.snapshot_marks();
4838        self.buffer.push_undo_entry(hjkl_buffer::UndoEntry {
4839            rope,
4840            cursor,
4841            timestamp,
4842            marks,
4843        });
4844        self.cap_undo();
4845        self.buffer.clear_redo();
4846    }
4847
4848    /// Trim the undo stack down to `settings.undo_levels`, dropping
4849    /// the oldest entries. `undo_levels == 0` is treated as
4850    /// "unlimited" (vim's 0-means-no-undo semantics intentionally
4851    /// skipped — guarding with `> 0` is one line shorter than gating
4852    /// the cap path with an explicit zero-check above the call site).
4853    pub(crate) fn cap_undo(&mut self) {
4854        let cap = self.settings.undo_levels as usize;
4855        self.buffer.cap_undo(cap);
4856    }
4857
4858    /// Test-only accessor for the undo stack length.
4859    #[doc(hidden)]
4860    pub fn undo_stack_len(&self) -> usize {
4861        self.buffer.undo_stack_len()
4862    }
4863
4864    /// Replace the buffer with `lines` joined by `\n` and set the
4865    /// cursor to `cursor`. Used by undo / `:e!` / snapshot restore
4866    /// paths. Marks the editor dirty.
4867    ///
4868    /// Emits a single whole-buffer `ContentEdit` describing the
4869    /// transition so the syntax layer can apply it as an `InputEdit`
4870    /// on the retained tree and run an INCREMENTAL parse — tree-sitter
4871    /// reuses unchanged subtrees and `Tree::changed_ranges` reports
4872    /// just the bytes that differ, which lets the install path walk
4873    /// only the changed rows instead of the full viewport. Big undos
4874    /// that revert a large paste now refresh in ~1ms per affected
4875    /// row instead of a ~30ms full-viewport sync walk.
4876    pub fn restore(&mut self, lines: &[String], cursor: (usize, usize)) {
4877        let text = lines.join("\n");
4878        self.restore_text(&text, cursor);
4879    }
4880
4881    /// Restore the buffer from a `ropey::Rope` snapshot. Used by undo /
4882    /// redo: snapshots are stored as `Rope` (O(1) Arc-clone via
4883    /// `View::rope()`), so this avoids the full-document `to_string`
4884    /// materialization that the old `Arc<String>` snapshot path forced
4885    /// on every undo group boundary.
4886    ///
4887    /// Internally materializes the rope to a `String` for `restore_text`
4888    /// — paying the cost on the restore side instead of the snapshot
4889    /// side trades one ~3 MB build per undo for none-per-snapshot. Undo
4890    /// is user-initiated and rare; snapshots fire on every `i` / `o`.
4891    pub fn restore_rope(&mut self, rope: &ropey::Rope, cursor: (usize, usize)) {
4892        let text = rope.to_string();
4893        self.restore_text(&text, cursor);
4894    }
4895
4896    fn restore_text(&mut self, text: &str, cursor: (usize, usize)) {
4897        // Diff the old rope (O(1) Arc-clone) against the incoming text
4898        // to emit a minimal ContentEdit — without it the syntax layer's
4899        // tree.edit() marks the whole document changed and tree-sitter
4900        // cold-parses on every undo.
4901        let old_rope = self.buffer.rope();
4902        let edit = minimal_content_edit_rope(&old_rope, text);
4903
4904        crate::types::BufferEdit::replace_all(&mut self.buffer, text);
4905        buf_set_cursor_rc(&mut self.buffer, cursor.0, cursor.1);
4906
4907        // Bulk replace supersedes any prior queued edits.
4908        self.buffer.clear_pending_content_edits();
4909        self.buffer.push_pending_content_edit(edit);
4910        self.mark_content_dirty();
4911    }
4912
4913    // ─── Range-query helpers for partial-format dispatch (#119) ─────────────
4914
4915    /// Drain the row range set by the most recent auto-indent operation.
4916    ///
4917    /// Returns `Some((top_row, bot_row))` (inclusive) on the first call after
4918    /// an `=` / `==` / `=G` / Visual-`=` operator, then clears the stored
4919    /// value so a subsequent call returns `None`. The host (e.g. `apps/hjkl`)
4920    /// uses this to arm a brief visual flash over the reindented rows.
4921    pub fn take_last_indent_range(&mut self) -> Option<(usize, usize)> {
4922        self.last_indent_range.take()
4923    }
4924
4925    /// Replace rows `top..=bot` (0-based, inclusive) with `new_lines` via a
4926    /// single bounded [`hjkl_buffer::Edit::Replace`] splice.
4927    ///
4928    /// Shared by [`Editor::toggle_comment_range`] and
4929    /// [`Editor::filter_range`] (audit D1 / D4): both used to rebuild the
4930    /// entire document as a `Vec<String>` + rejoin on every call —
4931    /// O(document size) for a range-scoped edit — which made `gcc` /
4932    /// `gc{motion}` and `:!`/`:%!` filters cost a full-document
4933    /// reallocation even when touching a single line. Routing through
4934    /// [`Editor::mutate_edit`] instead touches only the affected char span
4935    /// in the rope, so cost is O(edit size).
4936    ///
4937    /// `new_lines` may have a different row count than `bot - top + 1`
4938    /// (a filter can add/remove/keep lines) — including empty (deletes
4939    /// the range entirely). This is why the caller passes rows rather
4940    /// than a pre-joined string: `&[]` (delete) and `&[String::new()]`
4941    /// (replace with one blank line) join to the same `""` but must
4942    /// splice differently — the former must also swallow one of the
4943    /// range's boundary newlines, the latter must not.
4944    ///
4945    /// The boundary-newline math mirrors `do_delete_range`'s
4946    /// `MotionKind::Line` case (which already handles vim's "last row
4947    /// keeps no trailing newline" rule): when `bot` is not the buffer's
4948    /// last row, the replace span runs through the newline *after* `bot`
4949    /// and the inserted text re-adds it; when `bot` *is* the last row,
4950    /// the span instead runs from the end of row `top - 1` (swallowing
4951    /// the newline *before* `top`) so the buffer never grows a trailing
4952    /// empty row that didn't exist before.
4953    ///
4954    /// Cursor lands at `(top, 0)` — vim-commentary / filter parity —
4955    /// overriding wherever [`hjkl_buffer::Edit::Replace`] would otherwise
4956    /// leave it (end of the inserted text).
4957    ///
4958    /// Callers must call [`Editor::push_undo`] first so the whole
4959    /// operation lands as a single undo step (same contract `restore`
4960    /// callers already followed).
4961    fn splice_row_range(&mut self, top: usize, bot: usize, new_lines: &[String]) {
4962        let row_count = buf_row_count(&self.buffer);
4963        let bot_is_last_row = bot + 1 >= row_count;
4964        let joined = new_lines.join("\n");
4965
4966        let (start, end, with) = if !bot_is_last_row {
4967            // Rows exist after `bot` — span through the newline that
4968            // separates `bot` from `bot + 1` and re-add it (unless the
4969            // range is being deleted outright, i.e. `new_lines` is empty).
4970            let with = if new_lines.is_empty() {
4971                String::new()
4972            } else {
4973                format!("{joined}\n")
4974            };
4975            (
4976                hjkl_buffer::Position::new(top, 0),
4977                hjkl_buffer::Position::new(bot + 1, 0),
4978                with,
4979            )
4980        } else if top > 0 {
4981            // `bot` is the last row but rows exist before `top` — span
4982            // from the end of row `top - 1` (swallowing the newline
4983            // before `top`) through end-of-buffer, mirroring the
4984            // linewise-delete "no trailing-newline orphan" rule.
4985            let prev_end_col = buf_line_chars(&self.buffer, top - 1);
4986            let bot_end_col = buf_line_chars(&self.buffer, bot);
4987            let with = if new_lines.is_empty() {
4988                String::new()
4989            } else {
4990                format!("\n{joined}")
4991            };
4992            (
4993                hjkl_buffer::Position::new(top - 1, prev_end_col),
4994                hjkl_buffer::Position::new(bot, bot_end_col),
4995                with,
4996            )
4997        } else {
4998            // Whole buffer is the range (`top == 0`, `bot` == last row).
4999            let bot_end_col = buf_line_chars(&self.buffer, bot);
5000            (
5001                hjkl_buffer::Position::new(0, 0),
5002                hjkl_buffer::Position::new(bot, bot_end_col),
5003                joined,
5004            )
5005        };
5006
5007        self.mutate_edit(hjkl_buffer::Edit::Replace { start, end, with });
5008        buf_set_cursor_rc(&mut self.buffer, top, 0);
5009    }
5010
5011    /// Filter rows `top_row..=bot_row` through an external shell command.
5012    ///
5013    /// Spawns `sh -c "<command>"` (or `cmd /C "<command>"` on Windows), pipes
5014    /// the selected lines (joined by `\n`) to stdin, and waits up to
5015    /// `timeout_secs` seconds (default 10) for the process to finish.
5016    ///
5017    /// On success: the rows are replaced with stdout. No trailing-newline trim.
5018    /// On non-zero exit, spawn failure, or timeout: returns `Err(stderr_or_msg)`
5019    /// without mutating the buffer.
5020    ///
5021    /// `top_row` and `bot_row` are clamped to the buffer's valid row range.
5022    pub fn filter_range(
5023        &mut self,
5024        top_row: usize,
5025        bot_row: usize,
5026        command: &str,
5027        timeout_secs: Option<u64>,
5028    ) -> Result<(), String> {
5029        use std::io::Write;
5030        use std::process::{Command, Stdio};
5031        use std::thread;
5032        use std::time::Instant;
5033
5034        if crate::policy::shell_disabled() {
5035            return Err(
5036                "shell commands are disabled in this mode (pass --allow-shell to enable)".into(),
5037            );
5038        }
5039
5040        let timeout = std::time::Duration::from_secs(timeout_secs.unwrap_or(10));
5041        let rope = crate::types::Query::rope(self.buffer());
5042        let line_count = rope.len_lines();
5043        let top = top_row.min(line_count.saturating_sub(1));
5044        let bot = bot_row.min(line_count.saturating_sub(1));
5045        let (top, bot) = (top.min(bot), top.max(bot));
5046        let input_text = crate::rope_util::rope_row_range_str(&rope, top, bot);
5047
5048        tracing::debug!(
5049            top_row = top,
5050            bot_row = bot,
5051            command = command,
5052            "filter_range: spawning shell command"
5053        );
5054
5055        #[cfg(not(windows))]
5056        let mut child = Command::new("sh")
5057            .args(["-c", command])
5058            .stdin(Stdio::piped())
5059            .stdout(Stdio::piped())
5060            .stderr(Stdio::piped())
5061            .spawn()
5062            .map_err(|e| format!("spawn failed: {e}"))?;
5063
5064        #[cfg(windows)]
5065        let mut child = Command::new("cmd")
5066            .args(["/C", command])
5067            .stdin(Stdio::piped())
5068            .stdout(Stdio::piped())
5069            .stderr(Stdio::piped())
5070            .spawn()
5071            .map_err(|e| format!("spawn failed: {e}"))?;
5072
5073        // Write stdin on a thread to avoid deadlock when output > pipe buffer.
5074        let mut stdin = child.stdin.take().ok_or("no stdin handle")?;
5075        let input_bytes = input_text.into_bytes();
5076        thread::spawn(move || {
5077            let _ = stdin.write_all(&input_bytes);
5078            // stdin drops here, signalling EOF to the child.
5079        });
5080
5081        // Drain stdout/stderr on separate threads so the child's pipes don't
5082        // fill and deadlock the child. Keep `child` here so we can kill it on
5083        // timeout.
5084        let mut stdout_pipe = child.stdout.take().ok_or("no stdout handle")?;
5085        let mut stderr_pipe = child.stderr.take().ok_or("no stderr handle")?;
5086        let stdout_thread = thread::spawn(move || {
5087            let mut buf = Vec::new();
5088            let _ = std::io::Read::read_to_end(&mut stdout_pipe, &mut buf);
5089            buf
5090        });
5091        let stderr_thread = thread::spawn(move || {
5092            let mut buf = Vec::new();
5093            let _ = std::io::Read::read_to_end(&mut stderr_pipe, &mut buf);
5094            buf
5095        });
5096
5097        // Poll try_wait until exit or timeout. On timeout: SIGKILL the child
5098        // (std Child::kill sends SIGKILL on Unix / TerminateProcess on Windows).
5099        // A proper TERM→KILL escalation would need nix/libc; skip for v1.
5100        let start = Instant::now();
5101        let status = loop {
5102            match child.try_wait() {
5103                Ok(Some(status)) => break status,
5104                Ok(None) => {
5105                    if start.elapsed() >= timeout {
5106                        tracing::debug!(command, "filter_range: timeout — killing child");
5107                        let _ = child.kill();
5108                        let _ = child.wait(); // reap so the OS can free resources
5109                        return Err(format!("command timed out after {}s", timeout.as_secs()));
5110                    }
5111                    thread::sleep(std::time::Duration::from_millis(20));
5112                }
5113                Err(e) => return Err(format!("wait failed: {e}")),
5114            }
5115        };
5116
5117        let stdout_bytes = stdout_thread.join().unwrap_or_default();
5118        let stderr_bytes = stderr_thread.join().unwrap_or_default();
5119
5120        if !status.success() {
5121            let stderr = String::from_utf8_lossy(&stderr_bytes).into_owned();
5122            tracing::debug!(
5123                command,
5124                exit_code = ?status.code(),
5125                "filter_range: command exited with non-zero status"
5126            );
5127            return Err(if stderr.is_empty() {
5128                format!("command exited with status {}", status.code().unwrap_or(-1))
5129            } else {
5130                stderr
5131            });
5132        }
5133
5134        let stdout = String::from_utf8_lossy(&stdout_bytes).into_owned();
5135        tracing::debug!(
5136            command,
5137            stdout_bytes = stdout_bytes.len(),
5138            "filter_range: command succeeded, replacing rows"
5139        );
5140
5141        // Replace rows `top..=bot` with the stdout lines — a single
5142        // bounded splice (audit D4), not a whole-document rebuild.
5143        // `stdout.lines()` already drops the trailing-newline sentinel —
5144        // this preserves vim's "no trailing-newline trim" spec because a
5145        // trailing '\n' from the command means the last replacement line
5146        // is the line BEFORE the newline, not an empty line after it.
5147        let new_lines: Vec<String> = stdout.lines().map(|l| l.to_owned()).collect();
5148
5149        self.push_undo();
5150        self.splice_row_range(top, bot, &new_lines);
5151        // Leave the editor idle after a successful filter (vim parity: Normal).
5152        // Goes through the discipline hook, so the engine does not name vim.
5153        self.discipline.reset_to_idle();
5154
5155        Ok(())
5156    }
5157
5158    // ─── Comment toggle (#187) ───────────────────────────────────────────────
5159
5160    /// Toggle line comments on rows `top_row..=bot_row` (0-based, inclusive).
5161    ///
5162    /// **Algorithm** (vim-commentary parity):
5163    ///
5164    /// 1. Determine the comment marker(s) for the active filetype.
5165    ///    Priority: `settings.commentstring` (`:set commentstring=…`) → per-filetype
5166    ///    default from `hjkl_lang::comment::commentstring_for_lang` → no-op.
5167    /// 2. Scan non-blank lines.  If every non-blank line is already commented →
5168    ///    strip the comment marker from each.  Otherwise → add it to all non-blank
5169    ///    lines.
5170    /// 3. Blank / whitespace-only lines are skipped (no marker added or removed).
5171    /// 4. The marker is inserted AFTER the leading whitespace (indent-preserving).
5172    /// 5. The entire operation is a single undo step.
5173    ///
5174    /// For block-comment languages (HTML, CSS) each line is individually wrapped
5175    /// as `start text end` (per-line block style, not one multi-line block).
5176    ///
5177    /// `top_row` and `bot_row` are clamped to the buffer's valid row range.
5178    pub fn toggle_comment_range(&mut self, top_row: usize, bot_row: usize) {
5179        use hjkl_lang::comment::commentstring_for_lang;
5180
5181        let lang = self.settings.filetype.clone();
5182
5183        // Resolve the comment markers.
5184        // If `settings.commentstring` is set (non-empty) parse `start %s end`
5185        // from it; otherwise fall back to the filetype table.
5186        let (start, end) = if !self.settings.commentstring.is_empty() {
5187            let cs = &self.settings.commentstring;
5188            if let Some(idx) = cs.find("%s") {
5189                let s = cs[..idx].trim_end().to_string();
5190                let e_raw = cs[idx + 2..].trim_start();
5191                let e: Option<String> = if e_raw.is_empty() {
5192                    None
5193                } else {
5194                    Some(e_raw.to_string())
5195                };
5196                (s, e)
5197            } else {
5198                // No %s placeholder — treat the whole string as start marker.
5199                (cs.clone(), None)
5200            }
5201        } else {
5202            match commentstring_for_lang(&lang) {
5203                Some((s, e)) => (s.to_string(), e.map(|v| v.to_string())),
5204                None => return, // no known comment syntax → no-op
5205            }
5206        };
5207
5208        let row_count = buf_row_count(&self.buffer);
5209        let top = top_row.min(row_count.saturating_sub(1));
5210        let bot = bot_row.min(row_count.saturating_sub(1));
5211
5212        // Collect all lines in the range.
5213        let lines: Vec<String> = (top..=bot)
5214            .map(|r| buf_line(&self.buffer, r).unwrap_or_default())
5215            .collect();
5216
5217        // Check whether every non-blank line is already commented.
5218        let all_commented = lines.iter().all(|line| {
5219            let trimmed = line.trim_start();
5220            if trimmed.is_empty() {
5221                return true; // blank lines don't count against "all commented"
5222            }
5223            if let Some(ref end_marker) = end {
5224                // Block style: line starts with start and ends with end.
5225                trimmed.starts_with(start.as_str())
5226                    && line.trim_end().ends_with(end_marker.as_str())
5227            } else {
5228                trimmed.starts_with(start.as_str())
5229            }
5230        });
5231
5232        let mut new_lines: Vec<String> = Vec::with_capacity(lines.len());
5233        for line in &lines {
5234            let trimmed = line.trim_start();
5235            if trimmed.is_empty() {
5236                // Blank line — leave as-is.
5237                new_lines.push(line.clone());
5238                continue;
5239            }
5240            let indent_len = line.len() - trimmed.len();
5241            let indent = &line[..indent_len];
5242
5243            if all_commented {
5244                // Uncomment: strip exactly one occurrence of start (+ optional space).
5245                if let Some(after_start) = trimmed.strip_prefix(start.as_str()) {
5246                    // Strip one leading space after the marker if present.
5247                    let after_space = after_start.strip_prefix(' ').unwrap_or(after_start);
5248                    // For block style also strip the trailing end marker.
5249                    let text = if let Some(ref end_marker) = end {
5250                        after_space
5251                            .trim_end()
5252                            .strip_suffix(end_marker.as_str())
5253                            .map_or(after_space, |s| s.trim_end())
5254                    } else {
5255                        after_space
5256                    };
5257                    new_lines.push(format!("{indent}{text}"));
5258                } else {
5259                    new_lines.push(line.clone());
5260                }
5261            } else {
5262                // Comment: insert marker after indent.
5263                let commented = if let Some(ref end_marker) = end {
5264                    format!("{indent}{start} {trimmed} {end_marker}")
5265                } else {
5266                    format!("{indent}{start} {trimmed}")
5267                };
5268                new_lines.push(commented);
5269            }
5270        }
5271
5272        // Replace the row range in the buffer — single undo step, O(edit
5273        // size) rather than O(document size) (audit D1): `gcc` on one line
5274        // of a huge file no longer rebuilds the whole document.
5275        self.push_undo();
5276        self.splice_row_range(top, bot, &new_lines);
5277    }
5278
5279    // ─── Phase 6.1: public insert-mode primitives (kryptic-sh/hjkl#87) ────────
5280    //
5281    // Each method is the publicly callable form of one insert-mode action.
5282    // All logic lives in the corresponding `vim::*_bridge` free function;
5283    // these methods are thin delegators so the public surface stays on `Editor`.
5284    //
5285    // Invariants (enforced by the bridge fns):
5286    //   - View mutations go through `mutate_edit` (dirty/undo/change-list).
5287    //   - Navigation keys call `break_undo_group_in_insert` when the FSM did.
5288}
5289
5290// ── Phase 6.6b: FSM state accessors (for hjkl-vim ownership) ─────────────────
5291//
5292// The FSM (now in hjkl-vim) reads/writes `VimState` fields through public
5293// `Editor` accessors and mutators defined in this block. Each method gets a
5294// one-line `///` rustdoc. Fields mutated as a unit get a combined action method
5295// rather than individual getters + setters (e.g. `accumulate_count_digit`).
5296
5297impl<H: crate::types::Host> Editor<hjkl_buffer::View, H> {
5298    // ── Pending chord ─────────────────────────────────────────────────────────
5299
5300    // ── Abbreviations ─────────────────────────────────────────────────────────
5301
5302    /// Register an abbreviation. If an entry for `lhs` already exists (same
5303    /// mode flags), it is replaced. Inserts at the front so newer definitions
5304    /// take priority (first-match wins in `try_abbrev_expand`).
5305    pub fn add_abbrev(&mut self, lhs: &str, rhs: &str, insert: bool, cmdline: bool, noremap: bool) {
5306        let mut abbrevs = self.abbrevs.lock().unwrap();
5307        // Remove existing entry with same lhs + overlapping mode flags.
5308        abbrevs.retain(|a| a.lhs != lhs || (a.insert && !insert) || (a.cmdline && !cmdline));
5309        abbrevs.insert(
5310            0,
5311            crate::abbrev::Abbrev {
5312                lhs: lhs.to_string(),
5313                rhs: rhs.to_string(),
5314                insert,
5315                cmdline,
5316                noremap,
5317            },
5318        );
5319    }
5320
5321    /// Remove the abbreviation with the given `lhs`. Only removes entries
5322    /// whose mode flags overlap with the requested `insert`/`cmdline` flags.
5323    pub fn remove_abbrev(&mut self, lhs: &str, insert: bool, cmdline: bool) {
5324        self.abbrevs
5325            .lock()
5326            .unwrap()
5327            .retain(|a| a.lhs != lhs || (!insert || !a.insert) && (!cmdline || !a.cmdline));
5328    }
5329
5330    /// Clear all abbreviations matching the given mode flags.
5331    ///
5332    /// `insert=true` removes insert-mode abbrevs; `cmdline=true` removes
5333    /// cmdline-mode abbrevs. Both `true` clears everything.
5334    pub fn clear_abbrevs(&mut self, insert: bool, cmdline: bool) {
5335        self.abbrevs.lock().unwrap().retain(|a| {
5336            // Keep entries that do NOT match any of the cleared modes.
5337            let cleared = (insert && a.insert) || (cmdline && a.cmdline);
5338            !cleared
5339        });
5340    }
5341
5342    // ── Phase 6.6c: search + jump helpers (public Editor API) ───────────────
5343    //
5344    // `push_search_pattern`, `push_jump`, `record_search_history`, and
5345    // `walk_search_history` are public `Editor` methods so that `hjkl-vim`'s
5346    // search-prompt and normal-mode FSM can call them via the public API.
5347
5348    /// Compile `pattern` into a regex and install it as the active search
5349    /// pattern. Respects `:set ignorecase` / `:set smartcase` and inline
5350    /// `\c`/`\C` overrides. An empty or invalid pattern clears the highlight
5351    /// without raising an error.
5352    pub fn push_search_pattern(&mut self, pattern: &str) {
5353        let compiled = if pattern.is_empty() {
5354            None
5355        } else {
5356            use crate::search::{CaseMode, resolve_case_mode};
5357            let base =
5358                CaseMode::from_options(self.settings().ignore_case, self.settings().smartcase);
5359            let last_sub = self.last_substitute_replacement();
5360            let (stripped, mode) = resolve_case_mode(pattern, base, &last_sub);
5361            let src = if mode == CaseMode::Insensitive {
5362                format!("(?i){stripped}")
5363            } else {
5364                stripped
5365            };
5366            regex::Regex::new(&src).ok()
5367        };
5368        let wrap = self.settings().wrapscan;
5369        self.set_search_pattern(compiled);
5370        self.search_state_mut().wrap_around = wrap;
5371    }
5372
5373    /// Record a pre-jump cursor position onto the back jumplist. Called
5374    /// before any "big jump" motion (`gg`/`G`, `%`, `*`/`#`, `n`/`N`,
5375    /// committed `/` or `?`, …). Branching off the history clears the
5376    /// forward half, matching vim's "redo-is-lost" semantics.
5377    pub fn push_jump(&mut self, from: (usize, usize)) {
5378        self.jump_back.push(from);
5379        if self.jump_back.len() > crate::types::JUMPLIST_MAX {
5380            self.jump_back.remove(0);
5381        }
5382        self.jump_fwd.clear();
5383    }
5384
5385    /// Push `pattern` onto the committed search history. Skips if the
5386    /// most recent entry already matches (consecutive dedupe) and trims
5387    /// the oldest entries beyond the history cap.
5388    pub fn record_search_history(&mut self, pattern: &str) {
5389        if pattern.is_empty() {
5390            return;
5391        }
5392        let mut bank = self.search.lock().unwrap();
5393        if bank.history.last().map(String::as_str) == Some(pattern) {
5394            return;
5395        }
5396        bank.history.push(pattern.to_string());
5397        let len = bank.history.len();
5398        if len > crate::types::SEARCH_HISTORY_MAX {
5399            bank.history
5400                .drain(0..len - crate::types::SEARCH_HISTORY_MAX);
5401        }
5402    }
5403
5404    /// Walk the search-prompt history by `dir` steps. `dir = -1` moves
5405    /// toward older entries (Ctrl-P / Up); `dir = 1` toward newer ones
5406    /// (Ctrl-N / Down). Stops at the ends; does nothing if there is no
5407    /// active search prompt.
5408    pub fn walk_search_history(&mut self, dir: isize) {
5409        if self.search_prompt.is_none() {
5410            return;
5411        }
5412        let Some(text) = ({
5413            let mut bank = self.search.lock().unwrap();
5414            if bank.history.is_empty() {
5415                None
5416            } else {
5417                let len = bank.history.len();
5418                let next_idx = match (bank.history_cursor, dir) {
5419                    (None, -1) => Some(len - 1),
5420                    (None, 1) => None,
5421                    (Some(i), -1) => i.checked_sub(1),
5422                    (Some(i), 1) if i + 1 < len => Some(i + 1),
5423                    _ => None,
5424                };
5425                next_idx.map(|idx| {
5426                    bank.history_cursor = Some(idx);
5427                    bank.history[idx].clone()
5428                })
5429            }
5430        }) else {
5431            return;
5432        };
5433        if let Some(prompt) = self.search_prompt.as_mut() {
5434            prompt.cursor = text.chars().count();
5435            prompt.text.clone_from(&text);
5436        }
5437        self.push_search_pattern(&text);
5438    }
5439
5440    // The per-step prelude/epilogue (`begin_step`/`end_step` + `StepBookkeeping`)
5441    // moved to `hjkl_vim::step` (#267); the engine no longer owns FSM bookkeeping.
5442
5443    /// Return the character count (code-point count) of line `row`, or `0`
5444    /// when `row` is out of range.
5445    ///
5446    /// A raw buffer read with no vim semantics, so it stays on the engine core
5447    /// while the vim-specific visual/block primitives move to
5448    /// `hjkl_vim::VimEditorExt` (#267).
5449    pub fn line_char_count(&self, row: usize) -> usize {
5450        buf_line_chars(&self.buffer, row)
5451    }
5452}
5453
5454/// First `(row, col)` where two ropes differ, or `None` if identical. Used to
5455/// place the cursor at the start of a redone change (vim parity).
5456fn first_diff_pos(a: &ropey::Rope, b: &ropey::Rope) -> Option<(usize, usize)> {
5457    let rows = a.len_lines().max(b.len_lines());
5458    for r in 0..rows {
5459        let la = if r < a.len_lines() {
5460            hjkl_buffer::rope_line_str(a, r)
5461        } else {
5462            String::new()
5463        };
5464        let lb = if r < b.len_lines() {
5465            hjkl_buffer::rope_line_str(b, r)
5466        } else {
5467            String::new()
5468        };
5469        if la != lb {
5470            let col = la
5471                .chars()
5472                .zip(lb.chars())
5473                .take_while(|(x, y)| x == y)
5474                .count();
5475            return Some((r, col));
5476        }
5477    }
5478    None
5479}
5480
5481/// Visual column of the character at `char_col` in `line`, treating `\t`
5482/// as expansion to the next `tab_width` stop and every other char as
5483/// 1 cell wide. Wide-char support (CJK, emoji) is a separate concern —
5484/// the cursor math elsewhere also assumes single-cell chars.
5485fn visual_col_for_char(line: &str, char_col: usize, tab_width: usize) -> usize {
5486    let mut visual = 0usize;
5487    for (i, ch) in line.chars().enumerate() {
5488        if i >= char_col {
5489            break;
5490        }
5491        if ch == '\t' {
5492            visual += tab_width - (visual % tab_width);
5493        } else {
5494            visual += 1;
5495        }
5496    }
5497    visual
5498}
5499
5500#[cfg(test)]
5501mod shift_syntax_spans_tests {
5502    use super::*;
5503    use crate::types::{ContentEdit, DefaultHost, Options, Style};
5504    use hjkl_buffer::View;
5505
5506    fn ed_with_spans(line_count: usize) -> Editor<View, DefaultHost> {
5507        let text = (0..line_count)
5508            .map(|i| format!("row{i}"))
5509            .collect::<Vec<_>>()
5510            .join("\n");
5511        let buf = View::from_str(&text);
5512        let mut e = Editor::new(buf, DefaultHost::new(), Options::default());
5513        // Synthesize span rows so we can detect which survive a shift.
5514        // Use a distinct fg colour per row so spans are identifiable.
5515        let style = Style::default();
5516        let spans: Vec<Vec<(usize, usize, Style)>> =
5517            (0..line_count).map(|_| vec![(0, 1, style)]).collect();
5518        e.install_syntax_spans(spans);
5519        e
5520    }
5521
5522    fn edit_insert_newline_at(row: u32, col: u32) -> ContentEdit {
5523        // Pressing Enter: zero-width insertion that produces one new row.
5524        ContentEdit {
5525            start_byte: 0,
5526            old_end_byte: 0,
5527            new_end_byte: 1,
5528            start_position: (row, col),
5529            old_end_position: (row, col),
5530            new_end_position: (row + 1, 0),
5531        }
5532    }
5533
5534    fn edit_join_rows(row: u32, col: u32) -> ContentEdit {
5535        // Backspace at start of `row+1`: removes the newline, joining the
5536        // two rows. old_end is on `row+1`, new_end on `row`.
5537        ContentEdit {
5538            start_byte: 0,
5539            old_end_byte: 1,
5540            new_end_byte: 0,
5541            start_position: (row, col),
5542            old_end_position: (row + 1, 0),
5543            new_end_position: (row, col),
5544        }
5545    }
5546
5547    #[test]
5548    fn insert_grows_buffer_spans_in_place() {
5549        let mut e = ed_with_spans(4);
5550        // Newline at row 1 → buffer grew by one row.
5551        e.shift_syntax_spans_for_edits(&[edit_insert_newline_at(1, 1)]);
5552        assert_eq!(
5553            e.buffer_spans().len(),
5554            5,
5555            "row-count grew → spans rows must match"
5556        );
5557        // The empty row should be at index 2 (right after the split point).
5558        assert!(e.buffer_spans()[2].is_empty(), "inserted row sits at oer+1");
5559        // Surrounding rows kept their content.
5560        assert!(!e.buffer_spans()[0].is_empty());
5561        assert!(!e.buffer_spans()[1].is_empty());
5562        assert!(!e.buffer_spans()[3].is_empty());
5563        assert!(!e.buffer_spans()[4].is_empty());
5564    }
5565
5566    #[test]
5567    fn delete_shrinks_buffer_spans_in_place() {
5568        let mut e = ed_with_spans(4);
5569        e.shift_syntax_spans_for_edits(&[edit_join_rows(1, 1)]);
5570        assert_eq!(
5571            e.buffer_spans().len(),
5572            3,
5573            "row-count shrank → spans rows must match"
5574        );
5575    }
5576
5577    #[test]
5578    fn same_row_edit_leaves_rows_untouched() {
5579        let mut e = ed_with_spans(3);
5580        let edit = ContentEdit {
5581            start_byte: 0,
5582            old_end_byte: 0,
5583            new_end_byte: 1,
5584            start_position: (1, 0),
5585            old_end_position: (1, 0),
5586            new_end_position: (1, 1),
5587        };
5588        e.shift_syntax_spans_for_edits(&[edit]);
5589        assert_eq!(e.buffer_spans().len(), 3);
5590        for row in 0..3 {
5591            assert!(
5592                !e.buffer_spans()[row].is_empty(),
5593                "row {row} should still hold its span"
5594            );
5595        }
5596    }
5597
5598    #[test]
5599    fn ordered_edits_apply_against_prior_state() {
5600        let mut e = ed_with_spans(3);
5601        // Two consecutive inserts: each adds a row.
5602        e.shift_syntax_spans_for_edits(&[
5603            edit_insert_newline_at(0, 1),
5604            edit_insert_newline_at(1, 1),
5605        ]);
5606        assert_eq!(e.buffer_spans().len(), 5);
5607    }
5608
5609    /// The syntax worker lags the buffer, so a span table can arrive with
5610    /// MORE rows than the buffer currently has (e.g. `dd` landed between the
5611    /// parse and its delivery). Those rows must clamp to length 0 and drop
5612    /// their spans — never panic. Guards the row-length lookup, which reads
5613    /// the rope directly (`ropey::Rope::line` panics past the last line).
5614    #[test]
5615    fn install_tolerates_more_span_rows_than_buffer_rows() {
5616        let text = "aaaa\nbbbb\ncccc";
5617        let mut e = Editor::new(View::from_str(text), DefaultHost::new(), Options::default());
5618        let style = Style::default();
5619        let spans: Vec<Vec<(usize, usize, Style)>> = (0..10).map(|_| vec![(0, 4, style)]).collect();
5620        e.install_syntax_spans(spans);
5621        assert_eq!(e.buffer_spans().len(), 10, "one row per supplied entry");
5622        for row in 0..3 {
5623            assert_eq!(e.buffer_spans()[row].len(), 1, "row {row} keeps its span");
5624        }
5625        for row in 3..10 {
5626            assert!(
5627                e.buffer_spans()[row].is_empty(),
5628                "row {row} is past the buffer — clamps to 0 and drops"
5629            );
5630        }
5631    }
5632
5633    /// Spans are clamped in BYTES, not chars — a row of multi-byte text must
5634    /// keep a span that ends past the char count but inside the byte length.
5635    #[test]
5636    fn clamp_unit_is_bytes_not_chars() {
5637        // 5 chars, 10 bytes.
5638        let text = "ααααα\nx";
5639        let mut e = Editor::new(View::from_str(text), DefaultHost::new(), Options::default());
5640        let style = Style::default();
5641        e.install_syntax_spans(vec![vec![(0usize, 10usize, style)], vec![]]);
5642        assert_eq!(
5643            e.buffer_spans()[0][0].end_byte,
5644            10,
5645            "byte-length clamp must not truncate to the char count"
5646        );
5647        // One byte past the row is clamped back to the byte length.
5648        e.install_syntax_spans(vec![vec![(0usize, 11usize, style)], vec![]]);
5649        assert_eq!(e.buffer_spans()[0][0].end_byte, 10);
5650    }
5651
5652    /// Build a buffer with `line_count` rows where row `i` has a span at
5653    /// column `i + 1` so the rows are independently identifiable after a
5654    /// shift (otherwise all spans look identical and can't tell which
5655    /// original row's spans landed at which post-shift index).
5656    fn ed_with_distinguishable_spans(line_count: usize) -> Editor<View, DefaultHost> {
5657        let text = (0..line_count)
5658            .map(|i| format!("rowwwwwwwwww{i}"))
5659            .collect::<Vec<_>>()
5660            .join("\n");
5661        let buf = View::from_str(&text);
5662        let mut e = Editor::new(buf, DefaultHost::new(), Options::default());
5663        let style = Style::default();
5664        let spans: Vec<Vec<(usize, usize, Style)>> = (0..line_count)
5665            .map(|i| vec![(i + 1, i + 2, style)])
5666            .collect();
5667        e.install_syntax_spans(spans);
5668        e
5669    }
5670
5671    /// Regression for off-by-one in `shift_syntax_spans_for_edits`.
5672    ///
5673    /// `P` (paste-before) at column 0 of row 0 inserts new lines BEFORE
5674    /// row 0. The pre-paste rows should shift down by N. The fix inserts
5675    /// empty rows at idx `start.row` (not `oer + 1`) when `start.col == 0`.
5676    ///
5677    /// Symptom before the fix: row 0's spans stayed at idx 0 after a
5678    /// 4-row `ggP`, but the file's row 0 was now the pasted content (no
5679    /// spans available yet). Display: pasted row 0 painted with the
5680    /// pre-paste row 0's spans (LUCKILY identical content in many cases)
5681    /// while the *shifted* pre-paste row 0 (now at file row 4) painted
5682    /// with the pre-paste row 1's spans — visible as the WRONG row
5683    /// showing the wrong-row colours.
5684    #[test]
5685    fn shift_for_paste_at_start_of_row_zero() {
5686        let mut e = ed_with_distinguishable_spans(7);
5687        // Snapshot: row i has a span at col (i+1, i+2).
5688        let pre = e.buffer_spans().to_vec();
5689        // P at (0, 0) inserting 4 lines.
5690        let edit = ContentEdit {
5691            start_byte: 0,
5692            old_end_byte: 0,
5693            new_end_byte: 4,
5694            start_position: (0, 0),
5695            old_end_position: (0, 0),
5696            new_end_position: (4, 0),
5697        };
5698        e.shift_syntax_spans_for_edits(&[edit]);
5699        assert_eq!(e.buffer_spans().len(), 11, "row count grew by 4");
5700        // Rows 0..4 are the new pasted lines — should be EMPTY placeholders.
5701        for row in 0..4 {
5702            assert!(
5703                e.buffer_spans()[row].is_empty(),
5704                "row {row} (new paste) must be empty placeholder, got {:?}",
5705                e.buffer_spans()[row]
5706            );
5707        }
5708        // Rows 4..11 are the original rows 0..7 shifted down by 4.
5709        for (orig_row, orig_spans) in pre.iter().enumerate() {
5710            let new_row = orig_row + 4;
5711            assert_eq!(
5712                &e.buffer_spans()[new_row],
5713                orig_spans,
5714                "original row {orig_row} should be at file row {new_row} after \
5715                 paste-before-row-0"
5716            );
5717        }
5718    }
5719
5720    /// Same idea for paste at start of a non-zero row: `2GP` inserts 3
5721    /// lines before row 2.
5722    #[test]
5723    fn shift_for_paste_at_start_of_middle_row() {
5724        let mut e = ed_with_distinguishable_spans(5);
5725        let pre = e.buffer_spans().to_vec();
5726        // Insert 3 lines at (2, 0).
5727        let edit = ContentEdit {
5728            start_byte: 0,
5729            old_end_byte: 0,
5730            new_end_byte: 3,
5731            start_position: (2, 0),
5732            old_end_position: (2, 0),
5733            new_end_position: (5, 0),
5734        };
5735        e.shift_syntax_spans_for_edits(&[edit]);
5736        assert_eq!(e.buffer_spans().len(), 8);
5737        // Rows 0..2 unchanged (before the insertion point).
5738        assert_eq!(e.buffer_spans()[0], pre[0]);
5739        assert_eq!(e.buffer_spans()[1], pre[1]);
5740        // Rows 2..5 are new pasted lines.
5741        for row in 2..5 {
5742            assert!(
5743                e.buffer_spans()[row].is_empty(),
5744                "row {row} must be empty placeholder"
5745            );
5746        }
5747        // Rows 5..8 are originals 2..5 shifted down by 3.
5748        for (orig_row, orig_spans) in pre.iter().enumerate().take(5).skip(2) {
5749            let new_row = orig_row + 3;
5750            assert_eq!(
5751                &e.buffer_spans()[new_row],
5752                orig_spans,
5753                "original row {orig_row} should land at file row {new_row}"
5754            );
5755        }
5756    }
5757
5758    /// Regression: pasting N rows at the beginning of the buffer used to
5759    /// run `Vec::insert(0, ...)` once per row → O(N²) memmove. samply
5760    /// showed this path eating 87 % of paste CPU on a 60 k-row paste.
5761    /// The splice rewrite is O(N).
5762    ///
5763    /// Asserting a hard wall-clock bound is brittle on slow CI, so we
5764    /// pick a budget the old code blows past by >10×: 60 k rows in
5765    /// under 200 ms even on a debug build. Old impl: ~3-5 seconds.
5766    #[test]
5767    fn shift_for_60k_row_paste_at_row_zero_is_under_200ms() {
5768        let mut e = ed_with_distinguishable_spans(8);
5769        let edit = ContentEdit {
5770            start_byte: 0,
5771            old_end_byte: 0,
5772            new_end_byte: 60_000,
5773            start_position: (0, 0),
5774            old_end_position: (0, 0),
5775            new_end_position: (60_000, 0),
5776        };
5777        let t = std::time::Instant::now();
5778        e.shift_syntax_spans_for_edits(&[edit]);
5779        let elapsed = t.elapsed();
5780        assert!(
5781            elapsed.as_millis() < 200,
5782            "60k-row shift took {elapsed:?}; budget is 200 ms (catches \
5783             reintroduction of the O(N²) per-row insert loop)"
5784        );
5785        assert_eq!(e.buffer_spans().len(), 60_008);
5786    }
5787
5788    /// Regression: `push_undo` used to clone every line into a
5789    /// `Vec<String>` (162 k heap allocations on a 162 k-row buffer per
5790    /// snapshot). Now stores an `Arc<String>` shared with
5791    /// `View::content_joined`'s per-dirty_gen cache — a warm snapshot
5792    /// is an `Arc::clone` (one ptr bump).
5793    ///
5794    /// Test: snapshot a 60 k-row buffer 100 times. With the Arc impl
5795    /// this is essentially free (one join then 99 Arc::clones). The
5796    /// old `Vec<String>` impl required 60 k allocations per call =
5797    /// 6 M allocations, easily seconds even on release.
5798    #[test]
5799    fn push_undo_snapshot_arc_clone_is_under_100ms_for_100_snapshots() {
5800        use crate::types::{DefaultHost, Options};
5801        let text = "x\n".repeat(60_000);
5802        let buf = hjkl_buffer::View::from_str(&text);
5803        let mut e = Editor::new(buf, DefaultHost::default(), Options::default());
5804        // Warm the cache: one join, subsequent snapshots Arc::clone it.
5805        e.push_undo();
5806        let t = std::time::Instant::now();
5807        for _ in 0..100 {
5808            e.push_undo();
5809        }
5810        let elapsed = t.elapsed();
5811        assert!(
5812            elapsed.as_millis() < 100,
5813            "100 snapshots of a 60k-row buffer took {elapsed:?}; budget \
5814             100 ms. Likely regressed to per-line cloning."
5815        );
5816    }
5817}
5818
5819#[cfg(test)]
5820mod content_edit_shape_tests {
5821    //! Property tests for [`content_edits_from_buffer_edit`] (audit R2).
5822    //!
5823    //! The ground truth is the BUFFER: for any `hjkl_buffer::Edit`, the
5824    //! emitted `ContentEdit` sequence — applied to the pre-edit text by a
5825    //! naive sequential byte splicer — must reproduce the post-edit buffer
5826    //! text EXACTLY. The same sequence feeds tree-sitter `tree.edit`, LSP
5827    //! incremental didChange, sibling-cursor rebase and fold invalidation,
5828    //! all of which consume each edit against the document as already
5829    //! modified by the preceding edits in the batch.
5830
5831    use super::*;
5832    use hjkl_buffer::{Edit, MotionKind, Position, View};
5833
5834    /// Apply `edit` to a buffer built from `initial`, then replay the
5835    /// emitted `ContentEdit`s through a naive sequential splicer and
5836    /// assert the result equals the post-edit buffer text.
5837    ///
5838    /// Replacement text for edit `i` is sliced from the post-edit document
5839    /// at `[start_byte, new_end_byte)` shifted by the net byte delta of any
5840    /// edit that (a) hasn't been applied to the running splice yet (index
5841    /// `> i`) and (b) sits textually BEFORE edit `i` in the pre-edit
5842    /// document — such an edit is already baked into `post`'s layout at
5843    /// edit `i`'s position but hasn't been reflected in the splice yet.
5844    /// For an ascending-disjoint batch (`build_text_changes`'s own
5845    /// contract) no edit satisfies both conditions — every not-yet-applied
5846    /// edit sits AFTER, not before — so the shift is always 0 and this is
5847    /// exactly the plain `[start_byte, new_end_byte)` slice. For a
5848    /// descending fan-out (block ops, SplitLines — audit-r2 fix 5) EVERY
5849    /// not-yet-applied edit sits before, so this exactly cancels the
5850    /// layout shift their (already-baked-into-`post`) insertions cause.
5851    /// All six coordinates are cross-checked against the evolving
5852    /// document. Returns the edits so callers can additionally pin exact
5853    /// shapes.
5854    fn check_shapes(initial: &str, edit: Edit) -> Vec<crate::types::ContentEdit> {
5855        let mut view = View::from_str(initial);
5856        let edits = content_edits_from_buffer_edit(&view, &edit);
5857        view.apply_edit(edit);
5858        let post = view.as_string();
5859
5860        let mut cur = initial.to_string();
5861        for (i, e) in edits.iter().enumerate() {
5862            assert!(
5863                e.start_byte <= e.old_end_byte,
5864                "edit {i}: start_byte > old_end_byte\n{e:?}"
5865            );
5866            assert!(
5867                e.old_end_byte <= cur.len(),
5868                "edit {i}: old_end_byte {} past evolving doc len {}\n{e:?}",
5869                e.old_end_byte,
5870                cur.len()
5871            );
5872            assert_eq!(
5873                byte_to_row_col(cur.as_bytes(), e.start_byte),
5874                e.start_position,
5875                "edit {i}: start_position disagrees with start_byte\n{e:?}"
5876            );
5877            assert_eq!(
5878                byte_to_row_col(cur.as_bytes(), e.old_end_byte),
5879                e.old_end_position,
5880                "edit {i}: old_end_position disagrees with old_end_byte\n{e:?}"
5881            );
5882            let shift: i64 = edits[i + 1..]
5883                .iter()
5884                .filter(|other| other.start_byte < e.start_byte)
5885                .map(|other| other.new_end_byte as i64 - other.old_end_byte as i64)
5886                .sum();
5887            let post_start = (e.start_byte as i64 + shift) as usize;
5888            let post_new_end = (e.new_end_byte as i64 + shift) as usize;
5889            // A pure delete inserts nothing; its (empty) new range may sit
5890            // past the end of the final document, so short-circuit it the
5891            // way `build_text_changes`' clamping does.
5892            let replacement = if e.new_end_byte == e.start_byte {
5893                ""
5894            } else {
5895                post.get(post_start..post_new_end).unwrap_or_else(|| {
5896                    panic!(
5897                        "edit {i}: shifted [{post_start}, {post_new_end}) (raw [{}, {})) \
5898                         is not a valid slice of the post-edit doc ({} bytes)\n{e:?}",
5899                        e.start_byte,
5900                        e.new_end_byte,
5901                        post.len()
5902                    )
5903                })
5904            };
5905            assert!(
5906                cur.is_char_boundary(e.start_byte) && cur.is_char_boundary(e.old_end_byte),
5907                "edit {i}: old range splits a multi-byte char\n{e:?}"
5908            );
5909            cur.replace_range(e.start_byte..e.old_end_byte, replacement);
5910            assert_eq!(
5911                byte_to_row_col(cur.as_bytes(), e.new_end_byte),
5912                e.new_end_position,
5913                "edit {i}: new_end_position disagrees with new_end_byte\n{e:?}"
5914            );
5915        }
5916        assert_eq!(
5917            cur, post,
5918            "sequential splice of the emitted ContentEdits diverged from \
5919             the buffer's actual post-edit text"
5920        );
5921        edits
5922    }
5923
5924    fn join(row: usize, count: usize, with_space: bool) -> Edit {
5925        Edit::JoinLines {
5926            row,
5927            count,
5928            with_space,
5929        }
5930    }
5931
5932    fn del(start: (usize, usize), end: (usize, usize), kind: MotionKind) -> Edit {
5933        Edit::DeleteRange {
5934            start: Position::new(start.0, start.1),
5935            end: Position::new(end.0, end.1),
5936            kind,
5937        }
5938    }
5939
5940    /// `inserted_space` is a UNIFORM convenience for simple single- or
5941    /// mixed-intent test cases — broadcasts to every col. Tests that need
5942    /// genuinely mixed per-col outcomes (some joins inserted a space, some
5943    /// didn't — audit-r2 fix 6) construct `Edit::SplitLines` directly.
5944    fn split(row: usize, cols: Vec<usize>, inserted_space: bool) -> Edit {
5945        let inserted_spaces = vec![inserted_space; cols.len()];
5946        Edit::SplitLines {
5947            row,
5948            cols,
5949            inserted_spaces,
5950        }
5951    }
5952
5953    fn insert_block(at: (usize, usize), chunks: &[&str]) -> Edit {
5954        Edit::InsertBlock {
5955            at: Position::new(at.0, at.1),
5956            chunks: chunks.iter().map(|s| s.to_string()).collect(),
5957        }
5958    }
5959
5960    fn delete_block_chunks(at: (usize, usize), widths: Vec<usize>) -> Edit {
5961        let pads = vec![0; widths.len()];
5962        Edit::DeleteBlockChunks {
5963            at: Position::new(at.0, at.1),
5964            widths,
5965            pads,
5966        }
5967    }
5968
5969    // ── Shape 1: JoinLines ────────────────────────────────────────
5970
5971    /// Insert-mode Backspace at col 0 of "bar": the ONLY byte change is
5972    /// the '\n' at byte 3 being removed — "bar" stays in the buffer.
5973    #[test]
5974    fn join_backspace_at_col0_removes_only_the_newline() {
5975        let edits = check_shapes("foo\nbar\nbaz", join(0, 1, false));
5976        assert_eq!(edits.len(), 1);
5977        let e = &edits[0];
5978        assert_eq!(
5979            (e.start_byte, e.old_end_byte, e.new_end_byte),
5980            (3, 4, 3),
5981            "real change is [3, 4) → \"\"; got {e:?}"
5982        );
5983        assert_eq!(e.start_position, (0, 3));
5984        assert_eq!(e.old_end_position, (1, 0));
5985        assert_eq!(e.new_end_position, (0, 3));
5986    }
5987
5988    #[test]
5989    fn join_gj_style_no_space() {
5990        check_shapes("alpha\nbeta\ngamma", join(0, 1, false));
5991        check_shapes("alpha\nbeta\ngamma", join(1, 1, false));
5992    }
5993
5994    /// count=2 joins twice; each join's edit must be expressed against
5995    /// the document as modified by the previous join.
5996    #[test]
5997    fn join_count_two_emits_one_edit_per_join() {
5998        let edits = check_shapes("a\nb\nc\nd", join(0, 2, false));
5999        assert_eq!(edits.len(), 2, "one ContentEdit per join");
6000    }
6001
6002    #[test]
6003    fn join_with_space_inserts_single_space() {
6004        let edits = check_shapes("foo\nbar", join(0, 1, true));
6005        assert_eq!(edits.len(), 1);
6006        let e = &edits[0];
6007        assert_eq!((e.start_byte, e.old_end_byte, e.new_end_byte), (3, 4, 4));
6008        assert_eq!(e.new_end_position, (0, 4));
6009    }
6010
6011    /// `do_join_lines` skips the space when the incoming line is empty.
6012    #[test]
6013    fn join_with_space_next_line_empty_skips_space() {
6014        let edits = check_shapes("foo\n\nbar", join(0, 1, true));
6015        assert_eq!(edits.len(), 1);
6016        assert_eq!(edits[0].new_end_byte, edits[0].start_byte, "no space");
6017    }
6018
6019    /// count=2 with an empty middle line: join 1 inserts no space
6020    /// (suffix empty), join 2 does (both sides non-empty by then).
6021    #[test]
6022    fn join_with_space_count_two_over_empty_line() {
6023        let edits = check_shapes("foo\n\nbar", join(0, 2, true));
6024        assert_eq!(edits.len(), 2);
6025        assert_eq!(edits[0].new_end_byte, edits[0].start_byte);
6026        assert_eq!(edits[1].new_end_byte, edits[1].start_byte + 1);
6027    }
6028
6029    /// `do_join_lines` skips the space when the accumulated line is empty.
6030    #[test]
6031    fn join_with_space_prefix_empty_skips_space() {
6032        let edits = check_shapes("\nfoo", join(0, 1, true));
6033        assert_eq!(edits.len(), 1);
6034        assert_eq!(
6035            (
6036                edits[0].start_byte,
6037                edits[0].old_end_byte,
6038                edits[0].new_end_byte
6039            ),
6040            (0, 1, 0)
6041        );
6042    }
6043
6044    #[test]
6045    fn join_multibyte_lines() {
6046        // "héllo" = 6 bytes; the '\n' sits at byte 6.
6047        let edits = check_shapes("héllo\nwörld", join(0, 1, true));
6048        assert_eq!(edits.len(), 1);
6049        let e = &edits[0];
6050        assert_eq!((e.start_byte, e.old_end_byte, e.new_end_byte), (6, 7, 7));
6051        assert_eq!(e.start_position, (0, 6));
6052        assert_eq!(e.new_end_position, (0, 7));
6053        check_shapes("日本\n語だ\nよ", join(0, 2, false));
6054    }
6055
6056    /// The buffer stops joining when it runs out of rows; the emitted
6057    /// fan-out must stop with it.
6058    #[test]
6059    fn join_count_exceeding_rows_stops_at_last_join() {
6060        let edits = check_shapes("a\nb", join(0, 5, false));
6061        assert_eq!(edits.len(), 1, "only one join is possible");
6062    }
6063
6064    #[test]
6065    fn join_at_last_row_is_noop() {
6066        let edits = check_shapes("a\nb", join(1, 1, false));
6067        assert!(edits.is_empty(), "nothing to join → no ContentEdits");
6068    }
6069
6070    #[test]
6071    fn join_doc_with_trailing_newline() {
6072        // Lines: "foo", "" — joining consumes the trailing '\n'.
6073        let edits = check_shapes("foo\n", join(0, 1, false));
6074        assert_eq!(edits.len(), 1);
6075        assert_eq!(
6076            (
6077                edits[0].start_byte,
6078                edits[0].old_end_byte,
6079                edits[0].new_end_byte
6080            ),
6081            (3, 4, 3)
6082        );
6083    }
6084
6085    // ── Shape 2: linewise DeleteRange ending at the last row ─────
6086
6087    /// `dd` on the last row also removes the '\n' that ends the row
6088    /// above (matching `do_delete_range`), so the edit must start at
6089    /// EOL of row lo-1 and end at the true end of the document.
6090    #[test]
6091    fn linewise_delete_last_row_starts_at_prev_eol() {
6092        let edits = check_shapes("a\nb\nc", del((2, 0), (2, 0), MotionKind::Line));
6093        assert_eq!(edits.len(), 1);
6094        let e = &edits[0];
6095        assert_eq!(
6096            (e.start_byte, e.old_end_byte, e.new_end_byte),
6097            (3, 5, 3),
6098            "real change is [3, 5) → \"\"; got {e:?}"
6099        );
6100        assert_eq!(e.start_position, (1, 1));
6101        assert_eq!(e.old_end_position, (2, 1));
6102        assert_eq!(e.new_end_position, (1, 1));
6103    }
6104
6105    #[test]
6106    fn linewise_delete_multi_row_to_last() {
6107        let edits = check_shapes("a\nb\nc\nd", del((2, 0), (3, 0), MotionKind::Line));
6108        assert_eq!(edits.len(), 1);
6109        assert_eq!(
6110            (edits[0].start_byte, edits[0].old_end_byte),
6111            (3, 7),
6112            "[3, 7) covers \"\\nc\\nd\""
6113        );
6114    }
6115
6116    #[test]
6117    fn linewise_delete_whole_buffer() {
6118        let edits = check_shapes("a\nb\nc", del((0, 0), (2, 0), MotionKind::Line));
6119        assert_eq!(edits.len(), 1);
6120        let e = &edits[0];
6121        assert_eq!((e.start_byte, e.old_end_byte, e.new_end_byte), (0, 5, 0));
6122        assert_eq!(e.start_position, (0, 0));
6123        assert_eq!(e.old_end_position, (2, 1));
6124    }
6125
6126    #[test]
6127    fn linewise_delete_single_line_buffer() {
6128        check_shapes("abc", del((0, 0), (0, 0), MotionKind::Line));
6129    }
6130
6131    /// Regression guard: the not-at-end case was already correct.
6132    #[test]
6133    fn linewise_delete_interior_rows_unchanged() {
6134        let edits = check_shapes("a\nb\nc", del((0, 0), (1, 0), MotionKind::Line));
6135        assert_eq!(edits.len(), 1);
6136        let e = &edits[0];
6137        assert_eq!((e.start_byte, e.old_end_byte, e.new_end_byte), (0, 4, 0));
6138        assert_eq!(e.old_end_position, (2, 0));
6139    }
6140
6141    #[test]
6142    fn linewise_delete_last_row_multibyte() {
6143        // "aé" = 3 bytes, '\n' at 3, "bü" = 3 bytes → doc is 7 bytes.
6144        let edits = check_shapes("aé\nbü", del((1, 0), (1, 0), MotionKind::Line));
6145        assert_eq!(edits.len(), 1);
6146        let e = &edits[0];
6147        assert_eq!((e.start_byte, e.old_end_byte), (3, 7));
6148        assert_eq!(e.start_position, (0, 3));
6149        assert_eq!(e.old_end_position, (1, 3));
6150    }
6151
6152    /// End row past the last row must clamp like the buffer does.
6153    #[test]
6154    fn linewise_delete_end_row_overshoot_clamps() {
6155        check_shapes("a\nb\nc", del((1, 0), (9, 0), MotionKind::Line));
6156    }
6157
6158    /// Deleting the final empty line of a trailing-newline doc.
6159    #[test]
6160    fn linewise_delete_trailing_empty_last_row() {
6161        let edits = check_shapes("a\nb\n", del((2, 0), (2, 0), MotionKind::Line));
6162        assert_eq!(edits.len(), 1);
6163        assert_eq!((edits[0].start_byte, edits[0].old_end_byte), (3, 4));
6164    }
6165
6166    // ── Shape 3: visual-block delete fan-out ─────────────────────
6167
6168    /// Per-row edits carry pre-edit byte offsets, so they are only
6169    /// valid for a sequential consumer when emitted bottom-up.
6170    #[test]
6171    fn block_delete_emits_rows_descending() {
6172        let edits = check_shapes("abc\ndef\nghi", del((0, 0), (2, 1), MotionKind::Block));
6173        assert_eq!(edits.len(), 3);
6174        let rows: Vec<u32> = edits.iter().map(|e| e.start_position.0).collect();
6175        assert_eq!(
6176            rows,
6177            vec![2, 1, 0],
6178            "bottom-up so pre-edit offsets stay valid"
6179        );
6180        assert_eq!(
6181            (edits[0].start_byte, edits[0].old_end_byte),
6182            (8, 10),
6183            "row 2 cols 0..=1"
6184        );
6185    }
6186
6187    #[test]
6188    fn block_delete_multibyte() {
6189        // "éé" = 4 bytes + '\n' → row 1 starts at byte 5.
6190        let edits = check_shapes("éé\nüü", del((0, 0), (1, 0), MotionKind::Block));
6191        assert_eq!(edits.len(), 2);
6192        assert_eq!((edits[0].start_byte, edits[0].old_end_byte), (5, 7));
6193        assert_eq!((edits[1].start_byte, edits[1].old_end_byte), (0, 2));
6194    }
6195
6196    /// Rows shorter than the rectangle contribute nothing (matches
6197    /// `rope_cut_chars` clamping).
6198    #[test]
6199    fn block_delete_ragged_rows() {
6200        let edits = check_shapes("abcd\nx\nabcd", del((0, 1), (2, 2), MotionKind::Block));
6201        assert_eq!(edits.len(), 2, "middle row too short → skipped");
6202    }
6203
6204    /// End row past the last row: the buffer skips those rows; the
6205    /// fan-out must not emit clamped duplicates for them.
6206    #[test]
6207    fn block_delete_end_row_overshoot_clamps() {
6208        let edits = check_shapes("ab\ncd", del((0, 0), (5, 0), MotionKind::Block));
6209        assert_eq!(edits.len(), 2);
6210    }
6211
6212    // ── Shape 4: SplitLines (JoinLines inverse) ──────────────────
6213
6214    /// A single no-space split: the ONLY byte change is a '\n' inserted
6215    /// at the split col — mirrors `join_backspace_at_col0`'s inverse.
6216    #[test]
6217    fn split_single_col_no_space_inserts_newline() {
6218        let edits = check_shapes("foobar", split(0, vec![3], false));
6219        assert_eq!(edits.len(), 1);
6220        let e = &edits[0];
6221        assert_eq!((e.start_byte, e.old_end_byte, e.new_end_byte), (3, 3, 4));
6222        assert_eq!(e.start_position, (0, 3));
6223        assert_eq!(e.new_end_position, (1, 0));
6224    }
6225
6226    /// `inserted_space` REPLACES the space at the split col with '\n' —
6227    /// NOT a pure "\n " insert. `do_split_lines` removes the space then
6228    /// inserts '\n' at the same index: net 1 byte in, 1 byte out.
6229    #[test]
6230    fn split_with_space_replaces_the_space_not_inserts() {
6231        let edits = check_shapes("foo bar", split(0, vec![3], true));
6232        assert_eq!(edits.len(), 1);
6233        let e = &edits[0];
6234        assert_eq!(
6235            (e.start_byte, e.old_end_byte, e.new_end_byte),
6236            (3, 4, 4),
6237            "space at byte 3 replaced by '\\n' — 1 byte in, 1 byte out"
6238        );
6239        assert_eq!(e.new_end_position, (1, 0));
6240    }
6241
6242    /// Multiple splits (inverse of a count>1 join) apply RIGHT-TO-LEFT
6243    /// in the real buffer (`do_split_lines` iterates `cols.iter().rev()`)
6244    /// — same-row ascending pre-edit offsets would be wrong for a
6245    /// sequential consumer.
6246    #[test]
6247    fn split_multi_col_emits_descending() {
6248        // Inverse of joining "a", "b", "c" into "abc": cols = [1, 2].
6249        let edits = check_shapes("abc", split(0, vec![1, 2], false));
6250        assert_eq!(edits.len(), 2);
6251        let cols: Vec<u32> = edits.iter().map(|e| e.start_position.1).collect();
6252        assert_eq!(
6253            cols,
6254            vec![2, 1],
6255            "rightmost split first, matching do_split_lines"
6256        );
6257    }
6258
6259    /// Real round-trip: join then split the SAME buffer via the actual
6260    /// `do_join_lines`-produced inverse, count > 1 with an empty middle
6261    /// line — one join inserts no space (suffix empty), the other does.
6262    /// `check_shapes` cross-validates the SplitLines shape byte-exactly
6263    /// against this exact inverse, not a hand-picked one.
6264    #[test]
6265    fn split_round_trips_real_join_inverse_with_mixed_spaces() {
6266        let mut probe = View::from_str("foo\n\nbar");
6267        let inverse = probe.apply_edit(join(0, 2, true));
6268        let Edit::SplitLines {
6269            row: _,
6270            ref cols,
6271            ref inserted_spaces,
6272        } = inverse
6273        else {
6274            panic!("join's inverse must be SplitLines, got {inverse:?}");
6275        };
6276        assert_eq!(cols.len(), 2, "one recorded col per join");
6277        // First join (empty middle line as suffix) skips the space;
6278        // second join (now both sides non-empty) inserts one — per-col,
6279        // NOT the uniform with_space=true intent (audit-r2 fix 6).
6280        assert_eq!(
6281            inserted_spaces,
6282            &vec![false, true],
6283            "per-join outcome must reflect prefix/suffix emptiness, not just intent"
6284        );
6285        // The join's own inverse, replayed through content_edits_from_buffer_edit
6286        // against the joined ("foo bar") buffer, must byte-exactly reproduce
6287        // splitting it back apart.
6288        check_shapes("foo bar", inverse);
6289    }
6290
6291    /// A col at (or past) the split row's live end after a prior split
6292    /// truncated it: `do_split_lines` skips the space check (guard is
6293    /// `col < lc`) and falls through to a bare '\n' insert.
6294    #[test]
6295    fn split_duplicate_col_past_truncated_row_is_plain_insert() {
6296        let edits = check_shapes("foo bar", split(0, vec![3, 3], true));
6297        assert_eq!(edits.len(), 2);
6298        // First-processed (reverse order) col=3: space replaced by '\n'.
6299        assert_eq!(
6300            (
6301                edits[0].start_byte,
6302                edits[0].old_end_byte,
6303                edits[0].new_end_byte
6304            ),
6305            (3, 4, 4)
6306        );
6307        // Second-processed (also col=3, but now `3 < current_lc(=3)` is
6308        // false): plain '\n' insert, no deletion.
6309        assert_eq!(
6310            (
6311                edits[1].start_byte,
6312                edits[1].old_end_byte,
6313                edits[1].new_end_byte
6314            ),
6315            (3, 3, 4)
6316        );
6317    }
6318
6319    // ── Shape 5: InsertBlock fan-out ──────────────────────────────
6320
6321    /// Per-row edits carry pre-edit byte offsets, so — like block-delete
6322    /// — they are only valid for a sequential consumer when emitted
6323    /// bottom-up.
6324    #[test]
6325    fn insert_block_emits_rows_descending() {
6326        let edits = check_shapes("abc\ndef\nghi", insert_block((0, 1), &["X", "Y", "Z"]));
6327        assert_eq!(edits.len(), 3);
6328        let rows: Vec<u32> = edits.iter().map(|e| e.start_position.0).collect();
6329        assert_eq!(
6330            rows,
6331            vec![2, 1, 0],
6332            "bottom-up so pre-edit offsets stay valid"
6333        );
6334    }
6335
6336    #[test]
6337    fn insert_block_multibyte() {
6338        // "éé" = 4 bytes + '\n' → row 1 starts at byte 5.
6339        let edits = check_shapes("éé\nüü", insert_block((0, 1), &["x", "y"]));
6340        assert_eq!(edits.len(), 2);
6341    }
6342
6343    // ── Shape 6: DeleteBlockChunks fan-out ────────────────────────
6344
6345    #[test]
6346    fn delete_block_chunks_emits_rows_descending() {
6347        let edits = check_shapes("abc\ndef\nghi", delete_block_chunks((0, 0), vec![1, 1, 1]));
6348        assert_eq!(edits.len(), 3);
6349        let rows: Vec<u32> = edits.iter().map(|e| e.start_position.0).collect();
6350        assert_eq!(rows, vec![2, 1, 0]);
6351    }
6352
6353    /// A row too short for the block's column contributes nothing —
6354    /// matches `do_delete_block_chunks`'s per-row clamp.
6355    #[test]
6356    fn delete_block_chunks_ragged_rows_skip_empty() {
6357        let edits = check_shapes("abcd\nx\nabcd", delete_block_chunks((0, 2), vec![1, 1, 1]));
6358        assert_eq!(edits.len(), 2, "middle row too short → skipped");
6359    }
6360
6361    // ── Sanity: shapes that were already correct stay correct ────
6362
6363    #[test]
6364    fn charwise_and_insert_shapes_still_hold() {
6365        check_shapes("héllo wörld", del((0, 2), (0, 7), MotionKind::Char));
6366        check_shapes("a\nb\nc", del((0, 1), (2, 0), MotionKind::Char));
6367        check_shapes(
6368            "abc",
6369            Edit::InsertStr {
6370                at: Position::new(0, 1),
6371                text: "x\ny".to_string(),
6372            },
6373        );
6374        check_shapes(
6375            "abc\ndef",
6376            Edit::Replace {
6377                start: Position::new(0, 1),
6378                end: Position::new(1, 1),
6379                with: "Z\nQ".to_string(),
6380            },
6381        );
6382    }
6383}
6384
6385#[cfg(test)]
6386mod earlier_later_tests {
6387    use super::*;
6388    use crate::types::{DefaultHost, Options};
6389    use hjkl_buffer::View;
6390    use std::time::{Duration, SystemTime};
6391
6392    fn make_ed(content: &str) -> Editor<View, DefaultHost> {
6393        let buf = View::from_str(content);
6394        Editor::new(buf, DefaultHost::default(), Options::default())
6395    }
6396
6397    // ── step-based ───────────────────────────────────────────────────────────
6398
6399    #[test]
6400    fn earlier_by_steps_n_undoes_n_changes() {
6401        let mut ed = make_ed("hello");
6402        ed.push_undo(); // snap 1
6403        ed.push_undo(); // snap 2
6404        ed.push_undo(); // snap 3
6405        assert_eq!(ed.undo_stack_len(), 3);
6406        let applied = ed.earlier_by_steps(2);
6407        assert_eq!(applied, 2);
6408        assert_eq!(ed.undo_stack_len(), 1);
6409    }
6410
6411    #[test]
6412    fn earlier_by_steps_caps_at_stack_size() {
6413        let mut ed = make_ed("hello");
6414        ed.push_undo(); // snap 1
6415        // Ask for 10 but only 1 available.
6416        let applied = ed.earlier_by_steps(10);
6417        assert_eq!(applied, 1);
6418        assert_eq!(ed.undo_stack_len(), 0);
6419    }
6420
6421    #[test]
6422    fn later_by_steps_n_redoes_n_changes() {
6423        let mut ed = make_ed("hello");
6424        ed.push_undo(); // snap 1
6425        ed.push_undo(); // snap 2
6426        ed.push_undo(); // snap 3
6427        // Undo all 3 so they're on redo stack.
6428        ed.earlier_by_steps(3);
6429        assert_eq!(ed.undo_stack_len(), 0);
6430        let applied = ed.later_by_steps(2);
6431        assert_eq!(applied, 2);
6432        assert_eq!(ed.undo_stack_len(), 2);
6433    }
6434
6435    #[test]
6436    fn later_by_steps_caps_at_redo_stack_size() {
6437        let mut ed = make_ed("hello");
6438        ed.push_undo(); // snap 1
6439        ed.earlier_by_steps(1); // moves to redo
6440        let applied = ed.later_by_steps(99);
6441        assert_eq!(applied, 1);
6442    }
6443
6444    // ── time-based ───────────────────────────────────────────────────────────
6445
6446    fn epoch_plus(secs: u64) -> SystemTime {
6447        SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
6448    }
6449
6450    #[test]
6451    fn earlier_by_time_stops_at_target_boundary() {
6452        let mut ed = make_ed("hello");
6453        // Push 3 entries at t-30s, t-20s, t-10s (relative to epoch).
6454        ed.push_undo_at(epoch_plus(30));
6455        ed.push_undo_at(epoch_plus(40));
6456        ed.push_undo_at(epoch_plus(50));
6457        // Redo stack is empty; undo has 3 entries.
6458        // target = epoch+35 → should undo entries at t=50 and t=40, stop at t=30
6459        let target = epoch_plus(35);
6460        let applied = ed.earlier_by_time(target);
6461        assert_eq!(applied, 2, "should undo t=50 and t=40; stop at t=30");
6462        assert_eq!(ed.undo_stack_len(), 1, "t=30 entry remains");
6463    }
6464
6465    #[test]
6466    fn earlier_by_time_empty_stack_returns_zero() {
6467        let mut ed = make_ed("hello");
6468        let applied = ed.earlier_by_time(epoch_plus(999));
6469        assert_eq!(applied, 0);
6470        assert_eq!(ed.undo_stack_len(), 0);
6471    }
6472
6473    #[test]
6474    fn later_by_time_target_in_future_redoes_all() {
6475        let mut ed = make_ed("hello");
6476        ed.push_undo_at(epoch_plus(10));
6477        ed.push_undo_at(epoch_plus(20));
6478        // Undo both → they move to redo stack with their timestamps preserved.
6479        ed.earlier_by_steps(2);
6480        // target far in future: should redo all.
6481        let applied = ed.later_by_time(epoch_plus(9999));
6482        assert_eq!(applied, 2);
6483        assert_eq!(ed.undo_stack_len(), 2);
6484    }
6485}
6486
6487// ─── modifiable / readonly semantics tests ────────────────────────────────────
6488
6489#[cfg(test)]
6490mod shared_registers_tests {
6491    use super::*;
6492    use crate::types::{DefaultHost, Options};
6493    use hjkl_buffer::View;
6494
6495    #[test]
6496    fn shared_register_bank_visible_across_editors() {
6497        let shared =
6498            std::sync::Arc::new(std::sync::Mutex::new(crate::registers::Registers::default()));
6499        let mut a = Editor::new(View::new(), DefaultHost::default(), Options::default());
6500        a.set_registers_arc(shared.clone());
6501        let mut b = Editor::new(View::new(), DefaultHost::default(), Options::default());
6502        b.set_registers_arc(shared.clone());
6503        // Write to editor A's unnamed register
6504        a.with_registers_mut(|r| {
6505            r.unnamed = crate::registers::Slot {
6506                text: "hello".to_string(),
6507                linewise: false,
6508                ..Default::default()
6509            };
6510        });
6511        // Read from editor B — same bank, no copy needed
6512        assert_eq!(b.with_registers(|r| r.unnamed.text.clone()), "hello");
6513    }
6514
6515    /// #279 slice 4: the `linewise` flag on a `Slot` must travel with the
6516    /// shared register bank, not just the text — `do_paste`
6517    /// (hjkl-vim/src/vim/command.rs) sources its linewise decision from the
6518    /// selected register slot precisely so a whole-line yank in one window
6519    /// pastes linewise in a sibling window. This proves the shared `Arc`
6520    /// carries that bit, independent of the per-editor `yank_linewise` bool
6521    /// (which is deliberately NOT shared — see its doc comment).
6522    #[test]
6523    fn shared_register_bank_linewise_visible_across_editors() {
6524        let shared =
6525            std::sync::Arc::new(std::sync::Mutex::new(crate::registers::Registers::default()));
6526        let mut a = Editor::new(View::new(), DefaultHost::default(), Options::default());
6527        a.set_registers_arc(shared.clone());
6528        let mut b = Editor::new(View::new(), DefaultHost::default(), Options::default());
6529        b.set_registers_arc(shared.clone());
6530        // Write a LINEWISE yank to editor A's unnamed register.
6531        a.with_registers_mut(|r| {
6532            r.unnamed = crate::registers::Slot {
6533                text: "hello\n".to_string(),
6534                linewise: true,
6535                ..Default::default()
6536            };
6537        });
6538        // Read from editor B — same bank, so the linewise bit must be
6539        // visible too, not just the text.
6540        assert!(
6541            b.with_registers(|r| r.unnamed.linewise),
6542            "editor B should see editor A's linewise flag through the \
6543             shared register Arc"
6544        );
6545    }
6546
6547    /// `with_registers` / `with_registers_mut` are the only sanctioned way
6548    /// to touch the register bank from outside `editor.rs` (audit item
6549    /// B4) — the lock must stay scoped to the closure, and the closure's
6550    /// return value must plumb through untouched so callers can extract
6551    /// owned data without holding a guard.
6552    #[test]
6553    fn with_registers_round_trip_and_return_value_plumbs_through() {
6554        let ed = Editor::new(View::new(), DefaultHost::default(), Options::default());
6555
6556        // Write path: with_registers_mut mutates in place and returns a
6557        // value derived from the mutation.
6558        let wrote = ed.with_registers_mut(|r| {
6559            r.unnamed = crate::registers::Slot {
6560                text: "round-trip".to_string(),
6561                linewise: false,
6562                ..Default::default()
6563            };
6564            r.unnamed.text.len()
6565        });
6566        assert_eq!(wrote, "round-trip".len());
6567
6568        // Read path: with_registers sees the write and its return value
6569        // is the owned data extracted inside the closure.
6570        let read = ed.with_registers(|r| r.unnamed.text.clone());
6571        assert_eq!(read, "round-trip");
6572    }
6573}
6574
6575// ─── shared global-marks bank tests (#279 slice 1) ────────────────────────────
6576
6577#[cfg(test)]
6578mod shared_global_marks_tests {
6579    use super::*;
6580    use crate::types::{DefaultHost, Options};
6581    use hjkl_buffer::View;
6582
6583    #[test]
6584    fn shared_global_marks_bank_visible_across_editors() {
6585        let shared = std::sync::Arc::new(std::sync::Mutex::new(std::collections::BTreeMap::new()));
6586        let mut a = Editor::new(View::new(), DefaultHost::default(), Options::default());
6587        a.set_global_marks_arc(shared.clone());
6588        let mut b = Editor::new(View::new(), DefaultHost::default(), Options::default());
6589        b.set_global_marks_arc(shared.clone());
6590        // Set a global mark on editor A.
6591        a.set_global_mark('A', 7, (3, 5));
6592        // Read from editor B — same bank, no copy needed.
6593        assert_eq!(b.global_mark('A'), Some((7, 3, 5)));
6594    }
6595
6596    #[test]
6597    fn unshared_global_marks_stay_isolated() {
6598        let mut a = Editor::new(View::new(), DefaultHost::default(), Options::default());
6599        let b = Editor::new(View::new(), DefaultHost::default(), Options::default());
6600        a.set_global_mark('A', 1, (0, 0));
6601        // No shared Arc wired — B must not see A's mark.
6602        assert_eq!(b.global_mark('A'), None);
6603    }
6604}
6605
6606// ─── shared last-substitute bank tests (#279 slice 2) ─────────────────────
6607
6608#[cfg(test)]
6609mod shared_last_substitute_tests {
6610    use super::*;
6611    use crate::types::{DefaultHost, Options};
6612    use hjkl_buffer::View;
6613
6614    fn dummy_cmd(replacement: &str) -> crate::substitute::SubstituteCmd {
6615        crate::substitute::SubstituteCmd {
6616            pattern: Some("foo".to_string()),
6617            replacement: replacement.to_string(),
6618            flags: crate::substitute::SubstFlags::default(),
6619            count: None,
6620        }
6621    }
6622
6623    #[test]
6624    fn shared_last_substitute_bank_visible_across_editors() {
6625        let shared = std::sync::Arc::new(std::sync::Mutex::new(None));
6626        let mut a = Editor::new(View::new(), DefaultHost::default(), Options::default());
6627        a.set_last_substitute_arc(shared.clone());
6628        let mut b = Editor::new(View::new(), DefaultHost::default(), Options::default());
6629        b.set_last_substitute_arc(shared.clone());
6630        // Run `:s` (set the last substitute) on editor A.
6631        a.set_last_substitute(dummy_cmd("bar"));
6632        // Read from editor B — same bank, no copy needed.
6633        assert_eq!(b.last_substitute(), Some(dummy_cmd("bar")));
6634    }
6635
6636    #[test]
6637    fn unshared_last_substitute_stays_isolated() {
6638        let mut a = Editor::new(View::new(), DefaultHost::default(), Options::default());
6639        let b = Editor::new(View::new(), DefaultHost::default(), Options::default());
6640        a.set_last_substitute(dummy_cmd("bar"));
6641        // No shared Arc wired — B must not see A's last substitute.
6642        assert_eq!(b.last_substitute(), None);
6643    }
6644}
6645
6646// ─── shared abbreviations bank tests (#279 slice 3) ───────────────────────
6647
6648#[cfg(test)]
6649mod shared_abbrevs_tests {
6650    use super::*;
6651    use crate::types::{DefaultHost, Options};
6652    use hjkl_buffer::View;
6653
6654    #[test]
6655    fn shared_abbrevs_bank_visible_across_editors() {
6656        let shared = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
6657        let mut a = Editor::new(View::new(), DefaultHost::default(), Options::default());
6658        a.set_abbrevs_arc(shared.clone());
6659        let mut b = Editor::new(View::new(), DefaultHost::default(), Options::default());
6660        b.set_abbrevs_arc(shared.clone());
6661        // Define an abbreviation on editor A.
6662        a.add_abbrev("foo", "bar", true, true, false);
6663        // Read from editor B — same bank, no copy needed.
6664        let b_abbrevs = b.abbrevs();
6665        assert_eq!(b_abbrevs.len(), 1);
6666        assert_eq!(b_abbrevs[0].lhs, "foo");
6667        assert_eq!(b_abbrevs[0].rhs, "bar");
6668    }
6669
6670    #[test]
6671    fn unshared_abbrevs_stay_isolated() {
6672        let mut a = Editor::new(View::new(), DefaultHost::default(), Options::default());
6673        let b = Editor::new(View::new(), DefaultHost::default(), Options::default());
6674        a.add_abbrev("foo", "bar", true, true, false);
6675        // No shared Arc wired — B must not see A's abbrev.
6676        assert!(b.abbrevs().is_empty());
6677    }
6678}
6679
6680// ─── shared search bank tests (audit B2) ──────────────────────────────────
6681
6682#[cfg(test)]
6683mod shared_search_tests {
6684    use super::*;
6685    use crate::types::{DefaultHost, Options};
6686    use hjkl_buffer::View;
6687
6688    #[test]
6689    fn shared_search_bank_visible_across_editors() {
6690        let shared = std::sync::Arc::new(std::sync::Mutex::new(SearchBank::default()));
6691        let mut a = Editor::new(View::new(), DefaultHost::default(), Options::default());
6692        a.set_search_arc(shared.clone());
6693        let mut b = Editor::new(View::new(), DefaultHost::default(), Options::default());
6694        b.set_search_arc(shared.clone());
6695        // Commit a search on editor A.
6696        a.set_last_search(Some("foo".to_string()), true);
6697        // Read from editor B — same bank, no copy needed.
6698        assert_eq!(b.last_search(), Some("foo".to_string()));
6699        assert!(b.last_search_forward());
6700    }
6701
6702    #[test]
6703    fn unshared_search_stays_isolated() {
6704        let mut a = Editor::new(View::new(), DefaultHost::default(), Options::default());
6705        let b = Editor::new(View::new(), DefaultHost::default(), Options::default());
6706        a.set_last_search(Some("foo".to_string()), true);
6707        // No shared Arc wired — B must not see A's search.
6708        assert_eq!(b.last_search(), None);
6709    }
6710}
6711
6712// ─── shared change-bank tests (audit B3) ──────────────────────────────────
6713//
6714// Unlike the banks above (one Arc shared by every editor in the app), the
6715// change bank is PER-BUFFER: the app keys one Arc per `buffer_id` and hands
6716// each editor the Arc for its CURRENT buffer. These tests model that at the
6717// `Editor` level — the "keyed by buffer_id" bookkeeping itself lives on the
6718// app (`App::change_bank_for`), which has no unit-test seam here.
6719
6720#[cfg(test)]
6721mod shared_change_bank_tests {
6722    use super::*;
6723    use crate::types::{DefaultHost, Options};
6724    use hjkl_buffer::{Edit, Position, View};
6725
6726    fn editor_with(content: &str) -> Editor<View, DefaultHost> {
6727        let mut e = Editor::new(View::new(), DefaultHost::default(), Options::default());
6728        e.set_content(content);
6729        e
6730    }
6731
6732    /// Move the cursor to `(row, col)` then insert `text` there via the
6733    /// core edit funnel. `mutate_edit` records the dot mark / changelist
6734    /// entry from the LIVE cursor position (matching real FSM edits, which
6735    /// always type at the cursor) — not from the `Edit`'s `at` field — so
6736    /// the cursor must be positioned first.
6737    fn record_insert(e: &mut Editor<View, DefaultHost>, row: usize, col: usize, text: &str) {
6738        e.jump_cursor(row, col);
6739        e.mutate_edit(Edit::InsertStr {
6740            at: Position::new(row, col),
6741            text: text.to_string(),
6742        });
6743    }
6744
6745    /// (1) Two editors wired to the same buffer's bank share a changelist —
6746    /// an edit recorded via A is visible to B's `last_edit_pos` / `change_list`.
6747    #[test]
6748    fn shared_change_bank_visible_across_editors_on_same_buffer() {
6749        let shared = std::sync::Arc::new(std::sync::Mutex::new(ChangeBank::default()));
6750        let mut a = editor_with("alpha\nbeta\ngamma\n");
6751        a.set_change_bank_arc(shared.clone());
6752        let mut b = editor_with("alpha\nbeta\ngamma\n");
6753        b.set_change_bank_arc(shared.clone());
6754
6755        // Edit via editor A (e.g. window A on a `:split`).
6756        record_insert(&mut a, 1, 0, "X");
6757
6758        // Editor B (a sibling window on the SAME buffer) must see A's edit
6759        // in both the dot mark and the changelist ring. Both record the
6760        // PRE-edit cursor position — (1, 0), where `record_insert` placed
6761        // the cursor before inserting "X" — matching vim's `g;` landing on
6762        // the start of a change, not one past it (verified against real
6763        // nvim; see the `mutate_edit` comment at the changelist push site).
6764        assert_eq!(b.last_edit_pos(), Some((1, 0)));
6765        let (list, _) = b.change_list();
6766        assert_eq!(list, vec![(1, 0)]);
6767    }
6768
6769    /// (2) NEGATIVE — two editors on DIFFERENT buffers (independent banks,
6770    /// as if on different buffer_ids) must stay isolated.
6771    #[test]
6772    fn unshared_change_banks_on_different_buffers_stay_isolated() {
6773        let mut a = editor_with("alpha\nbeta\ngamma\n");
6774        let b = editor_with("alpha\nbeta\ngamma\n");
6775        // No Arc shared — each editor keeps its own default bank, exactly
6776        // as if `a` and `b` were windows on two different buffer_ids.
6777        record_insert(&mut a, 1, 0, "X");
6778
6779        assert_eq!(a.last_edit_pos(), Some((1, 0)));
6780        assert_eq!(
6781            b.last_edit_pos(),
6782            None,
6783            "different buffer must not see A's edit"
6784        );
6785        let (b_list, _) = b.change_list();
6786        assert!(b_list.is_empty());
6787    }
6788
6789    /// (3) An editor switched from buffer X's bank to buffer Y's bank picks
6790    /// up Y's changelist — and X's bank is left untouched by the switch.
6791    #[test]
6792    fn switching_buffers_swaps_to_the_new_buffers_bank() {
6793        let bank_x = std::sync::Arc::new(std::sync::Mutex::new(ChangeBank::default()));
6794        let bank_y = std::sync::Arc::new(std::sync::Mutex::new(ChangeBank::default()));
6795
6796        let mut ed = editor_with("alpha\nbeta\ngamma\n");
6797        ed.set_change_bank_arc(bank_x.clone());
6798        record_insert(&mut ed, 0, 0, "X");
6799        assert_eq!(ed.last_edit_pos(), Some((0, 0)));
6800
6801        // Simulate the app retargeting this window's editor onto a
6802        // different buffer (e.g. `:e other.txt` in that window).
6803        ed.set_change_bank_arc(bank_y.clone());
6804
6805        // The editor now sees Y's (empty) bank, not X's edit.
6806        assert_eq!(
6807            ed.last_edit_pos(),
6808            None,
6809            "after switching buffers the editor must see the NEW buffer's bank"
6810        );
6811        let (list, _) = ed.change_list();
6812        assert!(list.is_empty());
6813
6814        // X's bank is untouched by the switch — a sibling window still on
6815        // buffer X (if any) would still see the edit.
6816        assert_eq!(bank_x.lock().unwrap().last_edit, Some((0, 0)));
6817    }
6818}
6819
6820#[cfg(test)]
6821mod scroll_anim_tests {
6822    use super::*;
6823    use crate::types::{DefaultHost, Host, Options};
6824    use hjkl_buffer::View;
6825
6826    fn make_editor_with_content(content: &str) -> Editor<View, DefaultHost> {
6827        let mut buf = View::new();
6828        crate::types::BufferEdit::replace_all(&mut buf, content);
6829        let host = DefaultHost::new();
6830        Editor::new(buf, host, Options::default())
6831    }
6832
6833    #[test]
6834    fn scroll_duration_default_is_zero() {
6835        let buf = View::new();
6836        let host = DefaultHost::new();
6837        let ed = Editor::new(buf, host, Options::default());
6838        assert_eq!(ed.settings().scroll_duration_ms, 0);
6839    }
6840
6841    #[test]
6842    fn take_scroll_anim_hint_false_initially() {
6843        let buf = View::new();
6844        let host = DefaultHost::new();
6845        let mut ed = Editor::new(buf, host, Options::default());
6846        assert!(!ed.take_scroll_anim_hint());
6847    }
6848
6849    #[test]
6850    fn take_scroll_anim_hint_one_shot() {
6851        // Half-page scroll sets the hint; second drain clears it.
6852        let content: String = (0..50).map(|i| format!("line {i}\n")).collect();
6853        let mut ed = make_editor_with_content(&content);
6854        // Set viewport height so scroll actually moves
6855        ed.host_mut().viewport_mut().height = 20;
6856        ed.host_mut().viewport_mut().width = 80;
6857        ed.host_mut().viewport_mut().text_width = 80;
6858        ed.scroll_half_page(crate::types::ScrollDir::Down, 1);
6859        assert!(
6860            ed.take_scroll_anim_hint(),
6861            "hint should be set after half-page"
6862        );
6863        assert!(
6864            !ed.take_scroll_anim_hint(),
6865            "hint should be cleared on second drain"
6866        );
6867    }
6868
6869    #[test]
6870    fn line_scroll_does_not_set_hint() {
6871        let content: String = (0..50).map(|i| format!("line {i}\n")).collect();
6872        let mut ed = make_editor_with_content(&content);
6873        ed.host_mut().viewport_mut().height = 20;
6874        ed.host_mut().viewport_mut().width = 80;
6875        ed.host_mut().viewport_mut().text_width = 80;
6876        ed.scroll_line(crate::types::ScrollDir::Down, 1);
6877        assert!(
6878            !ed.take_scroll_anim_hint(),
6879            "hint must NOT be set for C-e/C-y"
6880        );
6881    }
6882}
6883
6884// ── UndoGranularity unit tests ───────────────────────────────────────────────
6885//
6886// These tests prove the critical invariant: vim (InsertSession) is byte-
6887// identical before and after this feature; Word granularity splits undo at
6888// word boundaries.
6889
6890#[cfg(test)]
6891mod undo_group_tests {
6892    use super::*;
6893    use crate::types::{DefaultHost, Options};
6894    use hjkl_buffer::{Edit, Position, View};
6895
6896    fn make_ed(content: &str) -> Editor<View, DefaultHost> {
6897        let buf = View::from_str(content);
6898        Editor::new(buf, DefaultHost::default(), Options::default())
6899    }
6900
6901    fn insert_x(ed: &mut Editor<View, DefaultHost>, row: usize) {
6902        ed.mutate_edit(Edit::InsertStr {
6903            at: Position::new(row, 0),
6904            text: "X".to_string(),
6905        });
6906    }
6907
6908    fn line0(ed: &Editor<View, DefaultHost>) -> String {
6909        hjkl_buffer::rope_line_str(&ed.buffer().rope(), 0)
6910    }
6911
6912    /// depth == 0: `push_undo` behaves exactly as before — every call snapshots
6913    /// (no coalescing), even with no intervening mutation.
6914    #[test]
6915    fn depth_zero_push_undo_is_unchanged() {
6916        let mut ed = make_ed("hello");
6917        ed.push_undo();
6918        ed.push_undo();
6919        ed.push_undo();
6920        assert_eq!(ed.undo_stack_len(), 3, "no coalescing outside a group");
6921    }
6922
6923    /// A group with one real mutation records exactly ONE entry, and a single
6924    /// undo reverts it.
6925    #[test]
6926    fn group_single_edit_is_one_entry() {
6927        let mut ed = make_ed("hello");
6928        {
6929            let _g = ed.undo_group();
6930            ed.push_undo();
6931            insert_x(&mut ed, 0);
6932        }
6933        assert_eq!(ed.undo_stack_len(), 1);
6934        assert_eq!(line0(&ed), "Xhello");
6935        ed.undo();
6936        assert_eq!(line0(&ed), "hello");
6937        assert_eq!(ed.undo_stack_len(), 0);
6938    }
6939
6940    /// Many `push_undo` + many edits inside one group still collapse to ONE
6941    /// entry, and one undo reverts the whole batch.
6942    #[test]
6943    fn group_coalesces_many_edits_into_one() {
6944        let mut ed = make_ed("hello");
6945        {
6946            let _g = ed.undo_group();
6947            for _ in 0..5 {
6948                ed.push_undo();
6949                insert_x(&mut ed, 0);
6950            }
6951        }
6952        assert_eq!(ed.undo_stack_len(), 1);
6953        assert_eq!(line0(&ed), "XXXXXhello");
6954        ed.undo();
6955        assert_eq!(line0(&ed), "hello", "one undo reverts every grouped edit");
6956    }
6957
6958    /// A group that pushes an undo but mutates nothing leaves ZERO entries.
6959    #[test]
6960    fn no_op_group_leaves_zero_entries() {
6961        let mut ed = make_ed("hello");
6962        {
6963            let _g = ed.undo_group();
6964            ed.push_undo();
6965            // no mutation
6966        }
6967        assert_eq!(
6968            ed.undo_stack_len(),
6969            0,
6970            "an unmutated group must discard its armed snapshot"
6971        );
6972    }
6973
6974    /// A completely empty group (no push_undo at all) leaves ZERO entries.
6975    #[test]
6976    fn empty_group_leaves_zero_entries() {
6977        let mut ed = make_ed("hello");
6978        {
6979            let _g = ed.undo_group();
6980        }
6981        assert_eq!(ed.undo_stack_len(), 0);
6982    }
6983
6984    /// Nested groups: only the OUTERMOST close commits; the inner guard drop
6985    /// does not, so the whole thing is one entry.
6986    #[test]
6987    fn nested_groups_commit_only_at_outermost() {
6988        let mut ed = make_ed("hello");
6989        {
6990            let _outer = ed.undo_group();
6991            ed.push_undo();
6992            insert_x(&mut ed, 0);
6993            {
6994                let _inner = ed.undo_group();
6995                ed.push_undo();
6996                insert_x(&mut ed, 0);
6997                // inner drops here: depth 2 -> 1, NOT committed yet.
6998            }
6999            assert_eq!(
7000                ed.undo_stack_len(),
7001                1,
7002                "inner close must not commit while the outer group is open"
7003            );
7004            insert_x(&mut ed, 0);
7005        }
7006        assert_eq!(
7007            ed.undo_stack_len(),
7008            1,
7009            "outer close commits the single entry"
7010        );
7011        assert_eq!(line0(&ed), "XXXhello");
7012        ed.undo();
7013        assert_eq!(line0(&ed), "hello");
7014    }
7015
7016    /// A group commits ONE entry that does not clobber a pre-existing entry:
7017    /// after a prior depth-0 change, a grouped change adds exactly one more.
7018    #[test]
7019    fn group_adds_single_entry_over_prior_history() {
7020        let mut ed = make_ed("hello");
7021        // Prior standalone change (depth 0).
7022        ed.push_undo();
7023        insert_x(&mut ed, 0);
7024        assert_eq!(ed.undo_stack_len(), 1);
7025        // Grouped change.
7026        {
7027            let _g = ed.undo_group();
7028            ed.push_undo();
7029            insert_x(&mut ed, 0);
7030            ed.push_undo();
7031            insert_x(&mut ed, 0);
7032        }
7033        assert_eq!(ed.undo_stack_len(), 2, "group adds exactly one entry");
7034        assert_eq!(line0(&ed), "XXXhello");
7035        ed.undo();
7036        assert_eq!(
7037            line0(&ed),
7038            "Xhello",
7039            "one undo reverts only the grouped edits"
7040        );
7041        ed.undo();
7042        assert_eq!(line0(&ed), "hello");
7043    }
7044}
7045
7046// ---- Settings ↔ Options conversion tests ----------------------------------
7047//
7048// `Settings::to_options` used to map ~20 fields and backfill the rest with
7049// `..Options::default()`, while `Settings::apply_options` wrote (nearly) all of
7050// them. Read-modify-apply callers (`Editor::current_options()` → tweak one
7051// field → `Editor::apply_options()`, e.g. the nvim-API `set_lines` modeline
7052// overlay) therefore silently reset every unmapped option to its SPEC default.
7053// These tests pin the seam shut.
7054
7055#[cfg(test)]
7056mod options_conversion_tests {
7057    use super::*;
7058    use crate::types::{
7059        DefaultHost, DiagInlineMode, FoldMethod, ListChars, Options, SignColumnMode, WrapMode,
7060    };
7061    use hjkl_buffer::View;
7062
7063    /// An `Options` value in which EVERY field differs from
7064    /// `Options::default()`, so a lossy conversion cannot hide behind a
7065    /// coincidentally-matching default.
7066    fn all_non_default_options() -> Options {
7067        let o = Options {
7068            tabstop: 7,
7069            shiftwidth: 3,
7070            expandtab: false,
7071            softtabstop: 2,
7072            iskeyword: "@,_,45".to_string(),
7073            ignorecase: false,
7074            smartcase: false,
7075            hlsearch: false,
7076            incsearch: false,
7077            wrapscan: false,
7078            autoindent: false,
7079            smartindent: false,
7080            timeout_len: core::time::Duration::from_millis(250),
7081            undo_levels: 42,
7082            undo_break_on_motion: false,
7083            readonly: true,
7084            modifiable: false,
7085            wrap: WrapMode::Word,
7086            textwidth: 100,
7087            number: false,
7088            relativenumber: true,
7089            numberwidth: 9,
7090            cursorline: true,
7091            cursorcolumn: false,
7092            signcolumn: SignColumnMode::Yes,
7093            foldcolumn: 3,
7094            foldmethod: FoldMethod::Marker,
7095            foldenable: false,
7096            foldlevelstart: 0,
7097            foldmarker: "<<<,>>>".to_string(),
7098            colorcolumn: "80,120".to_string(),
7099            formatoptions: "r".to_string(),
7100            filetype: "rust".to_string(),
7101            scrolloff: 11,
7102            sidescrolloff: 13,
7103            modeline: false,
7104            modelines: 8,
7105            autoreload: false,
7106            motion_sneak: false,
7107            list: true,
7108            listchars: ListChars {
7109                tab_lead: '»',
7110                tab_fill: None,
7111                space: Some('␣'),
7112                trail: Some('·'),
7113                eol: Some('¬'),
7114                nbsp: Some('⍽'),
7115                extends: Some('>'),
7116                precedes: Some('<'),
7117            },
7118            indent_guides: false,
7119            indent_guide_char: '|',
7120            colorizer: false,
7121            colorizer_filetypes: vec!["zig".to_string()],
7122            format_on_save: false,
7123            trim_trailing_whitespace: true,
7124            rainbow_brackets: false,
7125            updatetime: 250,
7126            matchparen: false,
7127            fixendofline: false,
7128        };
7129        // Guard the guard: if a future field lands with a value that happens to
7130        // equal the default, this literal stops proving anything for it.
7131        let d = Options::default();
7132        assert_ne!(o, d, "fixture must differ from Options::default()");
7133        o
7134    }
7135
7136    /// `apply_options` then `current_options` must be the IDENTITY on every
7137    /// field the engine stores. Fails today for `number`, `cursorline`,
7138    /// `signcolumn`, the fold options, `listchars`, `colorizer*`, … — the
7139    /// whole set `to_options` used to backfill from the default.
7140    #[test]
7141    fn settings_options_round_trip_is_identity() {
7142        let mut ed = Editor::new(View::new(), DefaultHost::new(), Options::default());
7143        let want = all_non_default_options();
7144        ed.apply_options(&want);
7145
7146        let got = ed.current_options();
7147        assert_eq!(
7148            got, want,
7149            "current_options() must echo apply_options() field-for-field"
7150        );
7151    }
7152
7153    /// A second pass must not drift: `apply(current())` is a fixed point.
7154    #[test]
7155    fn round_trip_is_idempotent() {
7156        let mut ed = Editor::new(View::new(), DefaultHost::new(), Options::default());
7157        ed.apply_options(&all_non_default_options());
7158        let first = ed.current_options();
7159        ed.apply_options(&first);
7160        assert_eq!(ed.current_options(), first);
7161    }
7162
7163    /// `hlsearch`, `incsearch`, `modeline` and `modelines` used to have no
7164    /// `Settings` storage, so `to_options` echoed the SPEC default and a
7165    /// caller could not move them. They are real fields now — this pins the
7166    /// direction of that change, so a regression that drops the storage again
7167    /// shows up as "echoed the default" rather than silently ignoring `:set`.
7168    #[test]
7169    fn search_and_modeline_options_round_trip_through_settings() {
7170        let mut ed = Editor::new(View::new(), DefaultHost::new(), Options::default());
7171        let want = all_non_default_options();
7172        ed.apply_options(&want);
7173        let got = ed.current_options();
7174        assert_eq!(got.hlsearch, want.hlsearch);
7175        assert_eq!(got.incsearch, want.incsearch);
7176        assert_eq!(got.modeline, want.modeline);
7177        assert_eq!(got.modelines, want.modelines);
7178        // …and they must differ from the SPEC default, or the assertions
7179        // above would pass on a `to_options` that still echoed it.
7180        let d = Options::default();
7181        assert_ne!(want.hlsearch, d.hlsearch);
7182        assert_ne!(want.incsearch, d.incsearch);
7183        assert_ne!(want.modeline, d.modeline);
7184        assert_ne!(want.modelines, d.modelines);
7185    }
7186
7187    /// `Settings::default()` and `Options::default()` must agree on every
7188    /// shared field. They used to disagree on `cursorline` (`Settings` said
7189    /// `false`, `Options` said `true`), so which default a session saw
7190    /// depended on whether it was built via `Editor::new(.., Options)` or via
7191    /// `Settings::default()` + `apply_options`.
7192    #[test]
7193    fn settings_default_matches_options_default() {
7194        let from_settings = Settings::default().to_options();
7195        let spec = Options::default();
7196        assert_eq!(
7197            from_settings, spec,
7198            "Settings::default() and Options::default() must not diverge"
7199        );
7200    }
7201
7202    /// The cursor-highlight pair is a deliberate hjkl divergence from vim:
7203    /// `cursorline` on, `cursorcolumn` off. What this test really guards is
7204    /// that the two sides agree — they disagreed once and the `Options` side
7205    /// silently won in a fresh session, so a fresh buffer showed a setting
7206    /// `:set cursorline?` denied.
7207    #[test]
7208    fn cursor_highlight_defaults_agree_on_both_sides() {
7209        assert!(Settings::default().cursorline);
7210        assert!(Options::default().cursorline);
7211        assert!(!Settings::default().cursorcolumn);
7212        assert!(!Options::default().cursorcolumn);
7213    }
7214
7215    /// `settings_from_options` (the `Editor::new` path) must agree with
7216    /// `apply_options` (the overlay path) for every field they share — the
7217    /// third hand-written conversion of the same shape.
7218    #[test]
7219    fn settings_from_options_agrees_with_apply_options() {
7220        let opts = all_non_default_options();
7221        let ctor = settings_from_options(&opts);
7222        let mut overlay = Settings::default();
7223        overlay.apply_options(&opts);
7224        assert_eq!(ctor.to_options(), overlay.to_options());
7225    }
7226
7227    /// The `WrapMode` ↔ `hjkl_buffer::Wrap` helpers are mutual inverses.
7228    #[test]
7229    fn wrap_helpers_round_trip() {
7230        for m in [WrapMode::None, WrapMode::Char, WrapMode::Word] {
7231            assert_eq!(wrap_to_mode(wrap_from_mode(m)), m);
7232        }
7233        for w in [
7234            hjkl_buffer::Wrap::None,
7235            hjkl_buffer::Wrap::Char,
7236            hjkl_buffer::Wrap::Word,
7237        ] {
7238            assert_eq!(wrap_from_mode(wrap_to_mode(w)), w);
7239        }
7240    }
7241
7242    /// Regression for the nvim-API seam (`nvim_api.rs` `set_lines`): a
7243    /// read-modify-apply cycle that touches ONE option must leave every other
7244    /// live option alone. Reproduced against the engine directly — the
7245    /// nvim-API path has no in-process harness.
7246    #[test]
7247    fn read_modify_apply_preserves_unrelated_options() {
7248        let mut ed = Editor::new(View::new(), DefaultHost::new(), Options::default());
7249        // A session that has diverged from the defaults (`:set` writes
7250        // `Settings` directly, so mirror that).
7251        ed.settings_mut().cursorline = true;
7252        ed.settings_mut().number = false;
7253        ed.settings_mut().filetype = "rust".to_string();
7254        ed.settings_mut().foldenable = false;
7255        ed.settings_mut().signcolumn = SignColumnMode::No;
7256        ed.settings_mut().diagnostics_inline = DiagInlineMode::Off;
7257
7258        // The seam: read, change one field, write back.
7259        let mut opts = ed.current_options();
7260        opts.tabstop = 2;
7261        ed.apply_options(&opts);
7262
7263        assert_eq!(ed.settings().tabstop, 2, "the edited field must land");
7264        assert!(ed.settings().cursorline, "cursorline must survive");
7265        assert!(!ed.settings().number, "number must survive");
7266        assert_eq!(ed.settings().filetype, "rust", "filetype must survive");
7267        assert!(!ed.settings().foldenable, "foldenable must survive");
7268        assert_eq!(ed.settings().signcolumn, SignColumnMode::No);
7269        // `Settings`-only field: not in `Options` at all, so untouched.
7270        assert_eq!(ed.settings().diagnostics_inline, DiagInlineMode::Off);
7271    }
7272}