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