Skip to main content

hjkl_engine/
editor.rs

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