Skip to main content

hjkl_engine/
vim.rs

1//! Vim-mode engine.
2//!
3//! Implements a command grammar of the form
4//!
5//! ```text
6//! Command := count? (operator count? (motion | text-object)
7//!                   | motion
8//!                   | insert-entry
9//!                   | misc)
10//! ```
11//!
12//! The parser is a small state machine driven by one `Input` at a time.
13//! Motions and text objects produce a [`Range`] (with inclusive/exclusive
14//! / linewise classification). A single [`Operator`] implementation
15//! applies a range — so `dw`, `d$`, `daw`, and visual `d` all go through
16//! the same code path.
17//!
18//! The most recent mutating command is stored in
19//! [`VimState::last_change`] so `.` can replay it.
20//!
21//! # Roadmap
22//!
23//! Tracked in the original plan at
24//! `~/.claude/plans/look-at-the-vim-curried-fern.md`. Phases still
25//! outstanding — each one can land as an isolated PR.
26//!
27//! ## P3 — Registers & marks
28//!
29//! - TODO: `RegisterBank` indexed by char:
30//!     - unnamed `""`, last-yank `"0`, small-delete `"-`
31//!     - named `"a-"z` (uppercase `"A-"Z` appends instead of overwriting)
32//!     - blackhole `"_`
33//!     - system clipboard `"+` / `"*` (wire to `crate::clipboard::Clipboard`)
34//!     - read-only `":`, `".`, `"%` — surface in `:reg` output
35//! - TODO: route every yank / cut / paste through the bank. Parser needs
36//!   a `"{reg}` prefix state that captures the target register before a
37//!   count / operator.
38//! - TODO: `m{a-z}` sets a mark in a `HashMap<char, (buffer_id, row, col)>`;
39//!   `'x` jumps to the line (FirstNonBlank), `` `x `` to the exact cell.
40//!   Uppercase marks are global across tabs; lowercase are per-buffer.
41//! - TODO: `''` and `` `` `` jump to the last-jump position; `'[` `']`
42//!   `'<` `'>` bound the last change / visual region.
43//! - TODO: `:reg` and `:marks` ex commands.
44//!
45//! ## P4 — Macros
46//!
47//! - TODO: `q{a-z}` starts recording raw `Input`s into the register;
48//!   next `q` stops.
49//! - TODO: `@{a-z}` replays the register by re-feeding inputs through
50//!   `step`. `@@` repeats the last macro. Nested macros need a sane
51//!   depth cap (e.g. 100) to avoid runaway loops.
52//! - TODO: ensure recording doesn't capture the initial `q{a-z}` itself.
53//!
54//! ## P6 — Polish (still outstanding)
55//!
56//! - TODO: indent operators `>` / `<` (with line + text-object targets).
57//! - TODO: format operator `=` — map to whatever SQL formatter we wire
58//!   up; for now stub that returns the range unchanged with a toast.
59//! - TODO: case operators `gU` / `gu` / `g~` on a range (already have
60//!   single-char `~`).
61//! - TODO: screen motions `H` / `M` / `L` once we track the render
62//!   viewport height inside Editor.
63//! - TODO: scroll-to-cursor motions `zz` / `zt` / `zb`.
64//!
65//! ## Known substrate / divergence notes
66//!
67//! - TODO: insert-mode indent helpers — `Ctrl-t` / `Ctrl-d` (increase /
68//!   decrease indent on current line) and `Ctrl-r <reg>` (paste from a
69//!   register). `Ctrl-r` needs the `RegisterBank` from P3 to be useful.
70//! - TODO: `/` and `?` search prompts still live in `the host/src/lib.rs`.
71//!   The plan calls for moving them into the editor (so the editor owns
72//!   `last_search_pattern` rather than the TUI loop). Safe to defer.
73
74use crate::VimMode;
75use crate::input::{Input, Key};
76
77use crate::buf_helpers::{
78    buf_cursor_pos, buf_line, buf_line_bytes, buf_line_chars, buf_row_count, buf_set_cursor_pos,
79    buf_set_cursor_rc,
80};
81use crate::editor::Editor;
82
83// ─── Modes & parser state ───────────────────────────────────────────────────
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
86pub enum Mode {
87    #[default]
88    Normal,
89    Insert,
90    Visual,
91    VisualLine,
92    /// Column-oriented selection (`Ctrl-V`). Unlike the other visual
93    /// modes this one doesn't use tui-textarea's single-range selection
94    /// — the block corners live in [`VimState::block_anchor`] and the
95    /// live cursor. Operators read the rectangle off those two points.
96    VisualBlock,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Default)]
100pub enum Pending {
101    #[default]
102    None,
103    /// Operator seen; still waiting for a motion / text-object / double-op.
104    /// `count1` is any count pressed before the operator.
105    Op { op: Operator, count1: usize },
106    /// Operator + 'i' or 'a' seen; waiting for the text-object character.
107    OpTextObj {
108        op: Operator,
109        count1: usize,
110        inner: bool,
111    },
112    /// Operator + 'g' seen (for `dgg`).
113    OpG { op: Operator, count1: usize },
114    /// Bare `g` seen in normal/visual — looking for `g`, `e`, `E`, …
115    G,
116    /// Bare `f`/`F`/`t`/`T` — looking for the target char.
117    Find { forward: bool, till: bool },
118    /// Operator + `f`/`F`/`t`/`T` — looking for target char.
119    OpFind {
120        op: Operator,
121        count1: usize,
122        forward: bool,
123        till: bool,
124    },
125    /// `r` pressed — waiting for the replacement char.
126    Replace,
127    /// Visual mode + `i` or `a` pressed — waiting for the text-object
128    /// character to extend the selection over.
129    VisualTextObj { inner: bool },
130    /// Bare `z` seen — looking for `z` (center), `t` (top), `b` (bottom).
131    Z,
132    /// `m` pressed — waiting for the mark letter to set.
133    SetMark,
134    /// `'` pressed — waiting for the mark letter to jump to its line
135    /// (lands on first non-blank, linewise for operators).
136    GotoMarkLine,
137    /// `` ` `` pressed — waiting for the mark letter to jump to the
138    /// exact `(row, col)` stored at set time (charwise for operators).
139    GotoMarkChar,
140    /// `"` pressed — waiting for the register selector. The next char
141    /// (`a`–`z`, `A`–`Z`, `0`–`9`, or `"`) sets `pending_register`.
142    SelectRegister,
143    /// `q` pressed (not currently recording) — waiting for the macro
144    /// register name. The macro records every key after the chord
145    /// resolves, until a bare `q` ends the recording.
146    RecordMacroTarget,
147    /// `@` pressed — waiting for the macro register name to play.
148    /// `count` is the prefix multiplier (`3@a` plays the macro 3
149    /// times); 0 means "no prefix" and is treated as 1.
150    PlayMacroTarget { count: usize },
151    /// `[` pressed in Normal/Visual mode — waiting for the second key.
152    /// Resolves `[[` → `SectionBackward`, `[]` → `SectionEndBackward`.
153    SquareBracketOpen,
154    /// `]` pressed in Normal/Visual mode — waiting for the second key.
155    /// Resolves `]]` → `SectionForward`, `][` → `SectionEndForward`.
156    SquareBracketClose,
157    /// Operator + `[` pending — waiting for second key to pick section motion.
158    OpSquareBracketOpen { op: Operator, count1: usize },
159    /// Operator + `]` pending — waiting for second key to pick section motion.
160    OpSquareBracketClose { op: Operator, count1: usize },
161    /// `s` / `S` in Normal mode with `motion_sneak=true` — waiting for
162    /// the first character of the two-char digraph.
163    /// `forward=true` → `s`; `forward=false` → `S` (backward).
164    SneakFirst { forward: bool, count: usize },
165    /// First sneak char captured; waiting for the second char to complete
166    /// the digraph and jump.
167    SneakSecond {
168        c1: char,
169        forward: bool,
170        count: usize,
171    },
172    /// Operator + `s` / `S` pending — waiting for the first char of the
173    /// two-char sneak digraph (e.g. `d` then `s` then `a` then `b` = `dsab`).
174    OpSneakFirst {
175        op: Operator,
176        count1: usize,
177        forward: bool,
178    },
179    /// Operator + sneak first char captured; waiting for the second char.
180    OpSneakSecond {
181        op: Operator,
182        count1: usize,
183        c1: char,
184        forward: bool,
185    },
186}
187
188// ─── Operator / Motion / TextObject ────────────────────────────────────────
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum Operator {
192    Delete,
193    Change,
194    Yank,
195    /// `gU{motion}` — uppercase the range. Entered via the `g` prefix
196    /// in normal mode or `U` in visual mode.
197    Uppercase,
198    /// `gu{motion}` — lowercase the range. `u` in visual mode.
199    Lowercase,
200    /// `g~{motion}` — toggle case of the range. `~` in visual mode
201    /// (character at the cursor for the single-char `~` command stays
202    /// its own code path in normal mode).
203    ToggleCase,
204    /// `>{motion}` — indent the line range by `shiftwidth` spaces.
205    /// Always linewise, even when the motion is char-wise — mirrors
206    /// vim's behaviour where `>w` indents the current line, not the
207    /// word on it.
208    Indent,
209    /// `<{motion}` — outdent the line range (remove up to
210    /// `shiftwidth` leading spaces per line).
211    Outdent,
212    /// `zf{motion}` / `zf{textobj}` / Visual `zf` — create a closed
213    /// fold spanning the row range. Doesn't mutate the buffer text;
214    /// cursor restores to the operator's start position.
215    Fold,
216    /// `gq{motion}` — reflow the row range to `settings.textwidth`.
217    /// Greedy word-wrap: collapses each paragraph (blank-line-bounded
218    /// run) into space-separated words, then re-emits lines whose
219    /// width stays under `textwidth`. Always linewise, like indent.
220    Reflow,
221    /// `gw{motion}` — same reflow as `gq` but cursor stays at the
222    /// pre-reflow `(row, col)`. If the reflow shrinks the line so the
223    /// original col is past the new EOL, the col is clamped to the last
224    /// char of the line (vim's behaviour). Always linewise.
225    ReflowKeepCursor,
226    /// `={motion}` — auto-indent the line range using shiftwidth-based
227    /// bracket depth counting (v1 dumb reindent). Always linewise.
228    /// See `auto_indent_range` for the algorithm and its limitations.
229    AutoIndent,
230    /// `!{motion}` — filter the line range through an external shell command.
231    /// The range text is piped to the command's stdin; stdout replaces the
232    /// range in the buffer. Non-zero exit or spawn failure returns an error
233    /// to the caller without mutating the buffer.
234    Filter,
235    /// `gc{motion}` / `gcc` — toggle line comments on the row range.
236    /// Dispatched through `Editor::toggle_comment_range` rather than the
237    /// normal `run_operator_over_range` pipeline (same pattern as `Filter`).
238    Comment,
239    /// `g?{motion}` / `g??` / visual `g?` — ROT13 the range. Same operator
240    /// shape as the case ops; only the per-char transform differs.
241    Rot13,
242}
243
244/// ROT13 a string: rotate ASCII letters by 13, leave everything else.
245pub(crate) fn rot13_str(s: &str) -> String {
246    s.chars()
247        .map(|c| match c {
248            'a'..='z' => (((c as u8 - b'a' + 13) % 26) + b'a') as char,
249            'A'..='Z' => (((c as u8 - b'A' + 13) % 26) + b'A') as char,
250            _ => c,
251        })
252        .collect()
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum Motion {
257    Left,
258    Right,
259    /// `<Space>` — right-motion that wraps to the next line at EOL (vim's
260    /// default `whichwrap=b,s`). Distinct from `Right`/`l` which never wrap.
261    SpaceFwd,
262    /// `<BS>` — left-motion that wraps to the previous line's last char at BOL
263    /// (`whichwrap=b`). Distinct from `Left`/`h` which never wrap.
264    BackspaceBack,
265    Up,
266    Down,
267    WordFwd,
268    BigWordFwd,
269    WordBack,
270    BigWordBack,
271    WordEnd,
272    BigWordEnd,
273    /// `ge` — backward word end.
274    WordEndBack,
275    /// `gE` — backward WORD end.
276    BigWordEndBack,
277    LineStart,
278    FirstNonBlank,
279    LineEnd,
280    FileTop,
281    FileBottom,
282    Find {
283        ch: char,
284        forward: bool,
285        till: bool,
286    },
287    FindRepeat {
288        reverse: bool,
289    },
290    MatchBracket,
291    /// `[(` / `])` / `[{` / `]}` — jump to the previous/next unmatched bracket
292    /// of the given kind. `open` is the open char (`(` or `{`); `forward` picks
293    /// the close (`)`/`}`) when true, the open when false.
294    UnmatchedBracket {
295        forward: bool,
296        open: char,
297    },
298    WordAtCursor {
299        forward: bool,
300        /// `*` / `#` use `\bword\b` boundaries; `g*` / `g#` drop them so
301        /// the search hits substrings (e.g. `foo` matches inside `foobar`).
302        whole_word: bool,
303    },
304    /// `n` / `N` — repeat the last `/` or `?` search.
305    SearchNext {
306        reverse: bool,
307    },
308    /// `H` — cursor to viewport top (plus `count - 1` rows down).
309    ViewportTop,
310    /// `M` — cursor to viewport middle.
311    ViewportMiddle,
312    /// `L` — cursor to viewport bottom (minus `count - 1` rows up).
313    ViewportBottom,
314    /// `g_` — last non-blank char on the line.
315    LastNonBlank,
316    /// `gM` — cursor to the middle char column of the current line
317    /// (`floor(chars / 2)`). Vim's variant ignoring screen wrap.
318    LineMiddle,
319    /// `gm` — cursor to the middle of the *screen* line: column
320    /// `min(viewport_width / 2, last_col)`. Differs from `gM` (char-middle).
321    ScreenLineMiddle,
322    /// `{` — previous paragraph (preceding blank line, or top).
323    ParagraphPrev,
324    /// `}` — next paragraph (following blank line, or bottom).
325    ParagraphNext,
326    /// `(` — previous sentence boundary.
327    SentencePrev,
328    /// `)` — next sentence boundary.
329    SentenceNext,
330    /// `gj` — `count` visual rows down (one screen segment per step
331    /// under `:set wrap`; falls back to `Down` otherwise).
332    ScreenDown,
333    /// `gk` — `count` visual rows up; mirror of [`Motion::ScreenDown`].
334    ScreenUp,
335    /// `[[` — backward to the previous `{` at column 0 (C section header).
336    /// Charwise exclusive; count-aware.
337    SectionBackward,
338    /// `]]` — forward to the next `{` at column 0. Charwise exclusive.
339    SectionForward,
340    /// `[]` — backward to the previous `}` at column 0 (C section end).
341    /// Charwise exclusive; count-aware.
342    SectionEndBackward,
343    /// `][` — forward to the next `}` at column 0. Charwise exclusive.
344    SectionEndForward,
345    /// `+` / `<CR>` — first non-blank of the next line. Linewise.
346    FirstNonBlankNextLine,
347    /// `-` — first non-blank of the previous line. Linewise.
348    FirstNonBlankPrevLine,
349    /// `_` — first non-blank of `count-1` lines down (count=1 = current line). Linewise.
350    FirstNonBlankLine,
351    /// `{count}|` — jump to column `count` on the current line (1-based;
352    /// no count or count=0 → column 1 → index 0). Clamped to line length.
353    GotoColumn,
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357pub enum TextObject {
358    Word {
359        big: bool,
360    },
361    Quote(char),
362    Bracket(char),
363    Paragraph,
364    /// `it` / `at` — XML/HTML-style tag pair. `inner = true` covers
365    /// content between `>` and `</`; `inner = false` covers the open
366    /// tag through the close tag inclusive.
367    XmlTag,
368    /// `is` / `as` — sentence: a run ending at `.`, `?`, or `!`
369    /// followed by whitespace or end-of-line. `inner = true` covers
370    /// the sentence text only; `inner = false` includes trailing
371    /// whitespace.
372    Sentence,
373}
374
375/// Classification determines how operators treat the range end.
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377pub enum RangeKind {
378    /// Range end is exclusive (end column not included). Typical: h, l, w, 0, $.
379    Exclusive,
380    /// Range end is inclusive. Typical: e, f, t, %.
381    Inclusive,
382    /// Whole lines from top row to bottom row. Typical: j, k, gg, G.
383    Linewise,
384}
385
386// ─── Dot-repeat storage ────────────────────────────────────────────────────
387
388/// Information needed to replay a mutating change via `.`.
389#[derive(Debug, Clone)]
390pub enum LastChange {
391    /// Operator over a motion.
392    OpMotion {
393        op: Operator,
394        motion: Motion,
395        count: usize,
396        inserted: Option<String>,
397    },
398    /// Operator over a text-object.
399    OpTextObj {
400        op: Operator,
401        obj: TextObject,
402        inner: bool,
403        inserted: Option<String>,
404    },
405    /// `dd`, `cc`, `yy` with a count.
406    LineOp {
407        op: Operator,
408        count: usize,
409        inserted: Option<String>,
410    },
411    /// `x`, `X` with a count.
412    CharDel { forward: bool, count: usize },
413    /// `r<ch>` with a count.
414    ReplaceChar { ch: char, count: usize },
415    /// `~` with a count.
416    ToggleCase { count: usize },
417    /// `J` with a count.
418    JoinLine { count: usize },
419    /// `p` / `P` (and `gp`/`gP`, `]p`/`[p`) with a count.
420    Paste {
421        before: bool,
422        count: usize,
423        /// `gp` / `gP` — leave the cursor just after the pasted text.
424        cursor_after: bool,
425        /// `]p` / `[p` — reindent the pasted block to the current line.
426        reindent: bool,
427    },
428    /// `D` (delete to EOL).
429    DeleteToEol { inserted: Option<String> },
430    /// `o` / `O` + the inserted text.
431    OpenLine { above: bool, inserted: String },
432    /// `i`/`I`/`a`/`A` + inserted text.
433    InsertAt {
434        entry: InsertEntry,
435        inserted: String,
436        count: usize,
437    },
438    /// `dgn` / `cgn` (and `gN` forms) — operate on the next search match.
439    /// `inserted` is filled on Esc for the `cgn` change form so `.` retypes it.
440    GnOp {
441        op: Operator,
442        forward: bool,
443        inserted: Option<String>,
444    },
445    /// `R{text}<Esc>` — replace (overstrike) mode. `.` re-overtypes `text`.
446    ReplaceMode { text: String },
447}
448
449#[derive(Debug, Clone, Copy, PartialEq, Eq)]
450pub enum InsertEntry {
451    I,
452    A,
453    ShiftI,
454    ShiftA,
455}
456
457// ─── VimState ──────────────────────────────────────────────────────────────
458
459/// Tracks which kind of horizontal jump was last performed so `;` / `,`
460/// can dispatch to the correct repeat handler.
461///
462/// - `FindChar` — last horizontal motion was `f`/`F`/`t`/`T`; `;`/`,`
463///   repeats via `Motion::FindRepeat`.
464/// - `Sneak` — last horizontal motion was `s`/`S` sneak; `;`/`,` repeats
465///   via `apply_sneak` with the stored digraph.
466/// - `None` — no horizontal motion yet; `;`/`,` are no-ops for both.
467#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
468pub enum LastHorizontalMotion {
469    #[default]
470    None,
471    FindChar,
472    Sneak,
473}
474
475/// A single abbreviation entry (insert-mode or cmdline-mode, recursive or noremap).
476///
477/// Mode flags: `insert` = expand in Insert mode, `cmdline` = expand in Cmdline mode.
478/// `noremap` stores whether the definition was made with `noreabbrev`; expansion
479/// is always literal text regardless of this flag, but it is preserved for future use.
480///
481/// NOTE: Abbreviations are currently per-editor (global in vim would share across
482/// buffers; per-editor is equivalent for single-buffer use and is acceptable for
483/// now — cross-buffer global behaviour is a follow-up).
484#[derive(Debug, Clone)]
485pub struct Abbrev {
486    pub lhs: String,
487    pub rhs: String,
488    pub insert: bool,
489    pub cmdline: bool,
490    pub noremap: bool,
491}
492
493/// Trigger kind for abbreviation expansion.
494#[derive(Debug, Clone, Copy, PartialEq, Eq)]
495pub enum AbbrevTrigger {
496    /// A non-keyword character was typed (e.g. space, punctuation).
497    NonKeyword(char),
498    /// `<C-]>` was pressed — expand without inserting any character.
499    CtrlBracket,
500    /// `<CR>` (Enter) was pressed.
501    Cr,
502    /// `<Esc>` was pressed to leave insert mode.
503    Esc,
504}
505
506#[derive(Default)]
507pub struct VimState {
508    /// Internal FSM mode. Kept in sync with `current_mode` after every
509    /// `step`. Phase 6.6b: promoted from private to `pub` so the FSM
510    /// body (moving to hjkl-vim in 6.6c–6.6g) can read/write it directly
511    /// until the migration is complete.
512    pub mode: Mode,
513    /// Two-key chord in progress. `Pending::None` when idle.
514    pub pending: Pending,
515    /// Digit prefix accumulated before an operator or motion. `0` means
516    /// no prefix was typed (treated as 1 by most commands).
517    pub count: usize,
518    /// Last `f`/`F`/`t`/`T` target, for `;` / `,` repeat.
519    pub last_find: Option<(char, bool, bool)>,
520    /// Most-recent mutating command for `.` dot-repeat.
521    pub last_change: Option<LastChange>,
522    /// Captured on insert-mode entry: count, buffer snapshot, entry kind.
523    pub insert_session: Option<InsertSession>,
524    /// (row, col) anchor for char-wise Visual mode. Set on entry, used
525    /// to compute the highlight range and the operator range without
526    /// relying on tui-textarea's live selection.
527    pub visual_anchor: (usize, usize),
528    /// Row anchor for VisualLine mode.
529    pub visual_line_anchor: usize,
530    /// (row, col) anchor for VisualBlock mode. The live cursor is the
531    /// opposite corner.
532    pub block_anchor: (usize, usize),
533    /// Intended "virtual" column for the block's active corner. j/k
534    /// clamp cursor.col to shorter rows, which would collapse the
535    /// block across ragged content — so we remember the desired column
536    /// separately and use it for block bounds / insert-column
537    /// computations. Updated by h/l only.
538    pub block_vcol: usize,
539    /// Track whether the last yank/cut was linewise (drives `p`/`P` layout).
540    pub yank_linewise: bool,
541    /// Active register selector — set by `"reg` prefix, consumed by
542    /// the next y / d / c / p. `None` falls back to the unnamed `"`.
543    pub pending_register: Option<char>,
544    /// Recording target — set by `q{reg}`, cleared by a bare `q`.
545    /// While `Some`, every consumed `Input` is appended to
546    /// `recording_keys`.
547    pub recording_macro: Option<char>,
548    /// Keys recorded into the in-progress macro. On `q` finish, these
549    /// are encoded via [`crate::input::encode_macro`] and written to
550    /// the matching named register slot, so macros and yanks share a
551    /// single store.
552    pub recording_keys: Vec<crate::input::Input>,
553    /// Set during `@reg` replay so the recorder doesn't capture the
554    /// replayed keystrokes a second time.
555    pub replaying_macro: bool,
556    /// Last register played via `@reg`. `@@` re-plays this one.
557    pub last_macro: Option<char>,
558    /// Position of the most recent buffer mutation. Surfaced via
559    /// the `'.` / `` `. `` marks for quick "back to last edit".
560    pub last_edit_pos: Option<(usize, usize)>,
561    /// Position where the cursor was when insert mode last exited (Esc).
562    /// Used by `gi` to return to the exact (row, col) where the user
563    /// last typed, matching vim's `:h gi`.
564    pub last_insert_pos: Option<(usize, usize)>,
565    /// Bounded ring of recent edit positions (newest at the back).
566    /// `g;` walks toward older entries, `g,` toward newer ones. Capped
567    /// at [`CHANGE_LIST_MAX`].
568    pub change_list: Vec<(usize, usize)>,
569    /// Index into `change_list` while walking. `None` outside a walk —
570    /// any new edit clears it (and trims forward entries past it).
571    pub change_list_cursor: Option<usize>,
572    /// Snapshot of the last visual selection for `gv` re-entry.
573    /// Stored on every Visual / VisualLine / VisualBlock exit.
574    pub last_visual: Option<LastVisual>,
575    /// `zz` / `zt` / `zb` set this so the end-of-step scrolloff
576    /// pass doesn't override the user's explicit viewport pinning.
577    /// Cleared every step.
578    pub viewport_pinned: bool,
579    /// Set by the 7 smooth-scrollable motions (C-d/u/f/b, zz/zt/zb) so the
580    /// app can animate the viewport jump. Drained via Editor::take_scroll_anim_hint.
581    pub scroll_anim_hint: bool,
582    /// Set while replaying `.` / last-change so we don't re-record it.
583    pub replaying: bool,
584    /// Entered Normal from Insert via `Ctrl-o`; after the next complete
585    /// normal-mode command we return to Insert.
586    pub one_shot_normal: bool,
587    /// Live `/` or `?` prompt. `None` outside search-prompt mode.
588    pub search_prompt: Option<SearchPrompt>,
589    /// Most recent committed search pattern. Surfaced to host apps via
590    /// [`Editor::last_search`] so their status line can render a hint
591    /// and so `n` / `N` have something to repeat.
592    pub last_search: Option<String>,
593    /// Direction of the last committed search. `n` repeats this; `N`
594    /// inverts it. Defaults to forward so a never-searched buffer's
595    /// `n` still walks downward.
596    pub last_search_forward: bool,
597    /// Text of the most recent insert session — vim's `".` register, pasted
598    /// via `<C-r>.` in insert mode (and `".p` in normal mode).
599    pub last_insert_text: Option<String>,
600    /// Back half of the jumplist — `Ctrl-o` pops from here. Populated
601    /// with the pre-motion cursor when a "big jump" motion fires
602    /// (`gg`/`G`, `%`, `*`/`#`, `n`/`N`, `H`/`M`/`L`, committed `/` or
603    /// `?`). Capped at 100 entries.
604    pub jump_back: Vec<(usize, usize)>,
605    /// Forward half — `Ctrl-i` pops from here. Cleared by any new big
606    /// jump, matching vim's "branch off trims forward history" rule.
607    pub jump_fwd: Vec<(usize, usize)>,
608    /// Set by `Ctrl-R` in insert mode while waiting for the register
609    /// selector. The next typed char names the register; its contents
610    /// are inserted inline at the cursor and the flag clears.
611    pub insert_pending_register: bool,
612    /// Stashed start position for the `[` mark on a Change operation.
613    /// Set to `top` before the cut in `run_operator_over_range` (Change
614    /// arm); consumed by `finish_insert_session` on Esc-from-insert
615    /// when the reason is `AfterChange`. Mirrors vim's `:h '[` / `:h ']`
616    /// rule that `[` = start of change, `]` = last typed char on exit.
617    pub change_mark_start: Option<(usize, usize)>,
618    /// Bounded history of committed `/` / `?` search patterns. Newest
619    /// entries are at the back; capped at [`SEARCH_HISTORY_MAX`] to
620    /// avoid unbounded growth on long sessions.
621    pub search_history: Vec<String>,
622    /// Index into `search_history` while the user walks past patterns
623    /// in the prompt via `Ctrl-P` / `Ctrl-N`. `None` outside that walk
624    /// — typing or backspacing in the prompt resets it so the next
625    /// `Ctrl-P` starts from the most recent entry again.
626    pub search_history_cursor: Option<usize>,
627    /// Wall-clock instant of the last keystroke. Drives the
628    /// `:set timeoutlen` multi-key timeout — if `now() - last_input_at`
629    /// exceeds the configured budget, any pending prefix is cleared
630    /// before the new key dispatches. `None` before the first key.
631    /// 0.0.29 (Patch B): `:set timeoutlen` math now reads
632    /// [`crate::types::Host::now`] via `last_input_host_at`. This
633    /// `Instant`-flavoured field stays for snapshot tests that still
634    /// observe it directly.
635    pub last_input_at: Option<std::time::Instant>,
636    /// `Host::now()` reading at the last keystroke. Drives
637    /// `:set timeoutlen` so macro replay / headless drivers stay
638    /// deterministic regardless of wall-clock skew.
639    pub last_input_host_at: Option<core::time::Duration>,
640    /// Canonical current mode. Mirrors `mode` (the FSM-internal field)
641    /// AND is written by every Phase 6.3 primitive (`set_mode`,
642    /// `enter_visual_char_bridge`, …). Once the FSM is gone this is the
643    /// sole source of truth; until then both fields are kept in sync.
644    /// Initialized to `Normal` via `#[derive(Default)]`.
645    pub(crate) current_mode: crate::VimMode,
646    /// Read-only view overlay layered over `current_mode` (git blame, …).
647    /// Orthogonal to the input mode: while `Blame`, input is still
648    /// interpreted as Normal but mutations are blocked and the host renders
649    /// the overlay. Auto-reset to `Normal` whenever the input mode leaves
650    /// `Normal` (see `drop_blame_if_left_normal`). Initialized to `Normal`.
651    pub(crate) view: crate::ViewMode,
652    /// Most recent successful :s invocation. Stored so :& / :&& can repeat it.
653    pub last_substitute: Option<crate::substitute::SubstituteCmd>,
654    /// Stack of auto-inserted closing characters awaiting skip-over.
655    ///
656    /// Each entry `(row, col, ch)` records where autopair placed a close
657    /// character. When the next typed char matches `ch` AND the cursor is
658    /// immediately before that position, the engine advances past it
659    /// ("skip-over") instead of inserting. The stack is cleared on any
660    /// cursor motion, mode change, or out-of-pair edit.
661    pub pending_closes: Vec<(usize, usize, char)>,
662    /// Last sneak digraph and direction: `Some(((c1, c2), forward))`.
663    /// Used by `;` / `,` sneak-repeat when `last_horizontal_motion == Sneak`.
664    pub last_sneak: Option<((char, char), bool)>,
665    /// Tracks which kind of horizontal motion was last performed, so `;` / `,`
666    /// can dispatch to sneak-repeat vs. find-char-repeat as appropriate.
667    pub last_horizontal_motion: LastHorizontalMotion,
668    /// Insert-mode (and cmdline-mode) abbreviations. Populated by `:abbreviate`,
669    /// `:iabbrev`, `:cabbrev`, `:noreabbrev`, etc. Empty by default.
670    pub abbrevs: Vec<Abbrev>,
671}
672
673pub(crate) const SEARCH_HISTORY_MAX: usize = 100;
674pub(crate) const CHANGE_LIST_MAX: usize = 100;
675
676/// Active `/` or `?` search prompt. Text mutations drive the textarea's
677/// live search pattern so matches highlight as the user types.
678#[derive(Debug, Clone)]
679pub struct SearchPrompt {
680    pub text: String,
681    pub cursor: usize,
682    pub forward: bool,
683    /// Operator-pending search (`d/pat`, `c/pat`, `y/pat`): the operator, its
684    /// count, and the cursor position where the operator started. `None` for a
685    /// plain `/` / `?` search. On commit the operator runs over the (exclusive,
686    /// charwise) range from `origin` to the match.
687    pub operator: Option<(Operator, usize, (usize, usize))>,
688}
689
690#[derive(Debug, Clone)]
691pub struct InsertSession {
692    pub count: usize,
693    /// Min/max row visited during this session. Widens on every key.
694    pub row_min: usize,
695    pub row_max: usize,
696    /// O(1) rope snapshot of the full buffer at session entry. Used to
697    /// diff the affected row window at finish without being fooled by
698    /// cursor navigation through rows the user never edited.
699    /// `ropey::Rope::clone` is Arc-clone — no byte copying.
700    pub before_rope: ropey::Rope,
701    pub reason: InsertReason,
702    /// (row, col) where the insert session began (char-indexed). Abbreviation
703    /// expansion uses `start_col` as `mincol` — only chars at or after this
704    /// column on `start_row` are eligible as part of the `lhs` match, so
705    /// pre-existing buffer text is never consumed by expansion.
706    pub start_row: usize,
707    pub start_col: usize,
708}
709
710#[derive(Debug, Clone)]
711pub enum InsertReason {
712    /// Plain entry via i/I/a/A — recorded as `InsertAt`.
713    Enter(InsertEntry),
714    /// Entry via `o`/`O` — records OpenLine on Esc.
715    Open { above: bool },
716    /// Entry via an operator's change side-effect. Retro-fills the
717    /// stored last-change's `inserted` field on Esc.
718    AfterChange,
719    /// Entry via `C` (delete to EOL + insert).
720    DeleteToEol,
721    /// Entry via an insert triggered during dot-replay — don't touch
722    /// last_change because the outer replay will restore it.
723    ReplayOnly,
724    /// `I` or `A` from VisualBlock: insert the typed text at `col` on
725    /// every row in `top..=bot`. `col` is the start column for `I`, the
726    /// one-past-block-end column for `A`.
727    BlockEdge { top: usize, bot: usize, col: usize },
728    /// `c` from VisualBlock: block content deleted, then user types
729    /// replacement text replicated across all block rows on Esc. Cursor
730    /// advances to the last typed char after replication (unlike BlockEdge
731    /// which leaves cursor at the insertion column).
732    BlockChange { top: usize, bot: usize, col: usize },
733    /// `R` — Replace mode. Each typed char overwrites the cell under
734    /// the cursor instead of inserting; at end-of-line the session
735    /// falls through to insert (same as vim).
736    Replace,
737}
738
739/// Saved visual-mode anchor + cursor for `gv` (re-enters the last
740/// visual selection). `mode` carries which visual flavour to
741/// restore; `anchor` / `cursor` mean different things per flavour:
742///
743/// - `Visual`     — `anchor` is the char-wise visual anchor.
744/// - `VisualLine` — `anchor.0` is the `visual_line_anchor` row;
745///   `anchor.1` is unused.
746/// - `VisualBlock`— `anchor` is `block_anchor`, `block_vcol` is the
747///   sticky vcol that survives j/k clamping.
748#[derive(Debug, Clone, Copy)]
749pub struct LastVisual {
750    pub mode: Mode,
751    pub anchor: (usize, usize),
752    pub cursor: (usize, usize),
753    pub block_vcol: usize,
754}
755
756impl VimState {
757    pub fn public_mode(&self) -> VimMode {
758        match self.mode {
759            Mode::Normal => VimMode::Normal,
760            Mode::Insert => VimMode::Insert,
761            Mode::Visual => VimMode::Visual,
762            Mode::VisualLine => VimMode::VisualLine,
763            Mode::VisualBlock => VimMode::VisualBlock,
764        }
765    }
766
767    pub fn force_normal(&mut self) {
768        self.mode = Mode::Normal;
769        self.pending = Pending::None;
770        self.count = 0;
771        self.insert_session = None;
772        // Phase 6.3: keep current_mode in sync for callers that bypass step().
773        self.current_mode = crate::VimMode::Normal;
774    }
775
776    /// Reset every prefix-tracking field so the next keystroke starts
777    /// a fresh sequence. Drives `:set timeoutlen` — when the user
778    /// pauses past the configured budget, `hjkl_vim::dispatch_input` calls
779    /// this before dispatching the new key.
780    ///
781    /// Resets: `pending`, `count`, `pending_register`,
782    /// `insert_pending_register`. Does NOT touch `mode`,
783    /// `insert_session`, marks, jump list, or visual anchors —
784    /// those aren't part of the in-flight chord.
785    pub(crate) fn clear_pending_prefix(&mut self) {
786        self.pending = Pending::None;
787        self.count = 0;
788        self.pending_register = None;
789        self.insert_pending_register = false;
790    }
791
792    /// Widen the active insert session's row window to include `row`. Called
793    /// by the Phase 6.1 public `Editor::insert_*` methods after each
794    /// mutation so `finish_insert_session` diffs the right range on Esc.
795    /// No-op when no insert session is active (e.g. calling from Normal mode).
796    pub(crate) fn widen_insert_row(&mut self, row: usize) {
797        if let Some(ref mut session) = self.insert_session {
798            session.row_min = session.row_min.min(row);
799            session.row_max = session.row_max.max(row);
800        }
801    }
802
803    pub fn is_visual(&self) -> bool {
804        matches!(
805            self.mode,
806            Mode::Visual | Mode::VisualLine | Mode::VisualBlock
807        )
808    }
809
810    pub fn is_visual_char(&self) -> bool {
811        self.mode == Mode::Visual
812    }
813
814    /// The pending repeat count (typed digits before a motion/operator),
815    /// or `None` when no digits are pending. Zero is treated as absent.
816    pub(crate) fn pending_count_val(&self) -> Option<u32> {
817        if self.count == 0 {
818            None
819        } else {
820            Some(self.count as u32)
821        }
822    }
823
824    /// `true` when an in-flight chord is awaiting more keys. Inverse of
825    /// `matches!(self.pending, Pending::None)`.
826    pub(crate) fn is_chord_pending(&self) -> bool {
827        !matches!(self.pending, Pending::None)
828    }
829
830    /// Return a single char representing the pending operator, if any.
831    /// Used by host apps (status line "showcmd" area) to display e.g.
832    /// `d`, `y`, `c` while waiting for a motion.
833    pub(crate) fn pending_op_char(&self) -> Option<char> {
834        let op = match &self.pending {
835            Pending::Op { op, .. }
836            | Pending::OpTextObj { op, .. }
837            | Pending::OpG { op, .. }
838            | Pending::OpFind { op, .. }
839            | Pending::OpSquareBracketOpen { op, .. }
840            | Pending::OpSquareBracketClose { op, .. } => Some(*op),
841            _ => None,
842        };
843        op.map(|o| match o {
844            Operator::Delete => 'd',
845            Operator::Change => 'c',
846            Operator::Yank => 'y',
847            Operator::Uppercase => 'U',
848            Operator::Lowercase => 'u',
849            Operator::ToggleCase => '~',
850            Operator::Indent => '>',
851            Operator::Outdent => '<',
852            Operator::Fold => 'z',
853            Operator::Reflow => 'q',
854            Operator::ReflowKeepCursor => 'w',
855            Operator::AutoIndent => '=',
856            Operator::Filter => '!',
857            // `gc` prefix — doubled as `gcc`.
858            Operator::Comment => 'c',
859            // `g?` prefix — doubled as `g??`.
860            Operator::Rot13 => '?',
861        })
862    }
863}
864
865// ─── Entry point ───────────────────────────────────────────────────────────
866
867/// Open the `/` (forward) or `?` (backward) search prompt. Clears any
868/// live search highlight until the user commits a query. `last_search`
869/// is preserved so an empty `<CR>` can re-run the previous pattern.
870pub(crate) fn enter_search<H: crate::types::Host>(
871    ed: &mut Editor<hjkl_buffer::Buffer, H>,
872    forward: bool,
873) {
874    ed.vim.search_prompt = Some(SearchPrompt {
875        text: String::new(),
876        cursor: 0,
877        forward,
878        operator: None,
879    });
880    ed.vim.search_history_cursor = None;
881    // 0.0.37: clear via the engine search state (the buffer-side
882    // bridge from 0.0.35 was removed in this patch — the `BufferView`
883    // renderer reads the pattern from `Editor::search_state()`).
884    ed.set_search_pattern(None);
885}
886
887/// `d/pat` / `c/pat` / `y/pat` (and `?` forms) — open the search prompt in
888/// operator-pending mode. On commit the operator runs over the exclusive
889/// charwise range from the current cursor to the match.
890pub(crate) fn enter_search_op<H: crate::types::Host>(
891    ed: &mut Editor<hjkl_buffer::Buffer, H>,
892    forward: bool,
893    op: Operator,
894    count: usize,
895) {
896    let origin = ed.cursor();
897    ed.vim.search_prompt = Some(SearchPrompt {
898        text: String::new(),
899        cursor: 0,
900        forward,
901        operator: Some((op, count.max(1), origin)),
902    });
903    ed.vim.search_history_cursor = None;
904    ed.set_search_pattern(None);
905}
906
907/// Apply a pending operator-search over the exclusive charwise range from
908/// `origin` to the current (post-search) cursor. Used by the search-prompt
909/// commit path for `d/` / `c/` / `y/`.
910pub(crate) fn apply_op_search_range<H: crate::types::Host>(
911    ed: &mut Editor<hjkl_buffer::Buffer, H>,
912    op: Operator,
913    origin: (usize, usize),
914) {
915    let target = ed.cursor();
916    run_operator_over_range(ed, op, origin, target, RangeKind::Exclusive);
917}
918
919/// `g;` / `g,` body. `dir = -1` walks toward older entries (g;),
920/// `dir = 1` toward newer (g,). `count` repeats the step. Stops at
921/// the ends of the ring; off-ring positions are silently ignored.
922fn walk_change_list<H: crate::types::Host>(
923    ed: &mut Editor<hjkl_buffer::Buffer, H>,
924    dir: isize,
925    count: usize,
926) {
927    if ed.vim.change_list.is_empty() {
928        return;
929    }
930    let len = ed.vim.change_list.len();
931    let mut idx: isize = match (ed.vim.change_list_cursor, dir) {
932        (None, -1) => len as isize - 1,
933        (None, 1) => return, // already past the newest entry
934        (Some(i), -1) => i as isize - 1,
935        (Some(i), 1) => i as isize + 1,
936        _ => return,
937    };
938    for _ in 1..count {
939        let next = idx + dir;
940        if next < 0 || next >= len as isize {
941            break;
942        }
943        idx = next;
944    }
945    if idx < 0 || idx >= len as isize {
946        return;
947    }
948    let idx = idx as usize;
949    ed.vim.change_list_cursor = Some(idx);
950    let (row, col) = ed.vim.change_list[idx];
951    ed.jump_cursor(row, col);
952}
953
954/// `Ctrl-R {reg}` body — insert the named register's contents at the
955/// cursor as charwise text. Embedded newlines split lines naturally via
956/// `Edit::InsertStr`. Unknown selectors and empty slots are no-ops so
957/// stray keystrokes don't mutate the buffer.
958fn insert_register_text<H: crate::types::Host>(
959    ed: &mut Editor<hjkl_buffer::Buffer, H>,
960    selector: char,
961) {
962    use hjkl_buffer::Edit;
963    // Special read-only registers: `/` = last search pattern, `.` = last
964    // inserted text. Fall back to the register store for everything else.
965    let text = match selector {
966        '/' => match &ed.vim.last_search {
967            Some(s) if !s.is_empty() => s.clone(),
968            _ => return,
969        },
970        '.' => match &ed.vim.last_insert_text {
971            Some(s) if !s.is_empty() => s.clone(),
972            _ => return,
973        },
974        _ => match ed.registers().read(selector) {
975            Some(slot) if !slot.text.is_empty() => slot.text.clone(),
976            _ => return,
977        },
978    };
979    ed.sync_buffer_content_from_textarea();
980    let cursor = buf_cursor_pos(&ed.buffer);
981    ed.mutate_edit(Edit::InsertStr {
982        at: cursor,
983        text: text.clone(),
984    });
985    // Advance cursor to the end of the inserted payload — multi-line
986    // pastes land on the last inserted row at the post-text column.
987    let mut row = cursor.row;
988    let mut col = cursor.col;
989    for ch in text.chars() {
990        if ch == '\n' {
991            row += 1;
992            col = 0;
993        } else {
994            col += 1;
995        }
996    }
997    buf_set_cursor_rc(&mut ed.buffer, row, col);
998    ed.push_buffer_cursor_to_textarea();
999    ed.mark_content_dirty();
1000    if let Some(ref mut session) = ed.vim.insert_session {
1001        session.row_min = session.row_min.min(row);
1002        session.row_max = session.row_max.max(row);
1003    }
1004}
1005
1006/// Compute the indent string to insert at the start of a new line
1007/// after Enter is pressed at `cursor`. Walks the smartindent rules:
1008///
1009/// - autoindent off → empty string
1010/// - autoindent on  → copy prev line's leading whitespace
1011/// - smartindent on → bump one `shiftwidth` if prev line's last
1012///   non-whitespace char is `{` / `(` / `[`
1013///
1014/// Indent unit (used for the smartindent bump):
1015///
1016/// - `expandtab && softtabstop > 0` → `softtabstop` spaces
1017/// - `expandtab` → `shiftwidth` spaces
1018/// - `!expandtab` → one literal `\t`
1019///
1020/// This is the placeholder for a future tree-sitter indent provider:
1021/// when a language has an `indents.scm` query, the engine will route
1022/// the same call through that provider and only fall back to this
1023/// heuristic when no query matches.
1024pub(super) fn compute_enter_indent(settings: &crate::editor::Settings, prev_line: &str) -> String {
1025    if !settings.autoindent {
1026        return String::new();
1027    }
1028    // Copy the prev line's leading whitespace (autoindent base).
1029    let base: String = prev_line
1030        .chars()
1031        .take_while(|c| *c == ' ' || *c == '\t')
1032        .collect();
1033
1034    if settings.smartindent {
1035        let unit = if settings.expandtab {
1036            if settings.softtabstop > 0 {
1037                " ".repeat(settings.softtabstop)
1038            } else {
1039                " ".repeat(settings.shiftwidth)
1040            }
1041        } else {
1042            "\t".to_string()
1043        };
1044
1045        // Open-bracket bump — language-agnostic.
1046        let last_non_ws = prev_line.chars().rev().find(|c| !c.is_whitespace());
1047        if matches!(last_non_ws, Some('{' | '(' | '[')) {
1048            return format!("{base}{unit}");
1049        }
1050
1051        // HTML-family opening-tag bump: `<head>` / `<div class="...">`.
1052        // Gated on filetype so Rust generics like `Vec<T>` don't trigger.
1053        // Reuses scan_tag_opener which already filters self-closing and
1054        // void elements.
1055        if is_html_filetype(&settings.filetype) {
1056            let trimmed_end_len = prev_line
1057                .trim_end_matches(|c: char| c.is_whitespace())
1058                .len();
1059            let trimmed = &prev_line[..trimmed_end_len];
1060            if let Some(stripped) = trimmed.strip_suffix('>')
1061                && scan_tag_opener(trimmed, stripped.len()).is_some()
1062            {
1063                return format!("{base}{unit}");
1064            }
1065        }
1066    }
1067
1068    base
1069}
1070
1071// ── Comment-continuation helpers ──────────────────────────────────────────
1072
1073/// Return the ordered (longest-first) list of line-comment prefixes for
1074/// `lang`. Each prefix includes one trailing space (e.g. `"// "`).
1075/// The same table lives in `hjkl-lang::comment` for the `gc` toggle (#187).
1076fn comment_prefixes_for_lang(lang: &str) -> &'static [&'static str] {
1077    match lang {
1078        "rust" => &["/// ", "//! ", "// "],
1079        "c" | "cpp" => &["// "],
1080        "python" | "sh" | "bash" | "zsh" | "fish" | "toml" | "yaml" => &["# "],
1081        "lua" => &["-- "],
1082        "sql" => &["-- "],
1083        "vim" | "viml" => &["\" "],
1084        _ => &[],
1085    }
1086}
1087
1088/// Detect whether `line` starts with a known comment prefix for `lang`.
1089///
1090/// Returns `Some((indent, prefix))` where `indent` is the leading whitespace
1091/// of the line and `prefix` is the canonical (with trailing space) comment
1092/// marker. Returns `None` when the line is not a recognised comment.
1093pub(crate) fn detect_comment_on_line(lang: &str, line: &str) -> Option<(String, &'static str)> {
1094    let indent_end = line
1095        .char_indices()
1096        .find(|(_, c)| *c != ' ' && *c != '\t')
1097        .map(|(i, _)| i)
1098        .unwrap_or(line.len());
1099    let indent = line[..indent_end].to_string();
1100    let rest = &line[indent_end..];
1101    for &prefix in comment_prefixes_for_lang(lang) {
1102        if rest.starts_with(prefix) {
1103            return Some((indent, prefix));
1104        }
1105        // Also match the bare prefix (line that is exactly `//` with no
1106        // trailing content).
1107        let bare = prefix.trim_end_matches(' ');
1108        if rest == bare || rest.starts_with(&format!("{bare} ")) {
1109            return Some((indent, prefix));
1110        }
1111    }
1112    None
1113}
1114
1115/// Given the current `row` in `buffer` and the active `settings`, return the
1116/// string to prepend on the new line when comment-continuation fires.
1117///
1118/// Returns `Some("<indent><prefix>")` when the row is a comment line and
1119/// continuation is appropriate, `None` otherwise. The caller appends the
1120/// string after the `\n` they are about to insert.
1121pub(crate) fn continue_comment(
1122    buffer: &hjkl_buffer::Buffer,
1123    settings: &crate::editor::Settings,
1124    row: usize,
1125) -> Option<String> {
1126    if settings.filetype.is_empty() {
1127        return None;
1128    }
1129    let line = crate::buf_helpers::buf_line(buffer, row)?;
1130    let (indent, prefix) = detect_comment_on_line(&settings.filetype, &line)?;
1131    Some(format!("{indent}{prefix}"))
1132}
1133
1134/// Strip one indent unit from the beginning of `line` and insert `ch`
1135/// instead. Returns `true` when it consumed the keystroke (dedent +
1136/// insert), `false` when the caller should insert normally.
1137///
1138/// Dedent fires when:
1139///   - `smartindent` is on
1140///   - `ch` is `}` / `)` / `]`
1141///   - all bytes BEFORE the cursor on the current line are whitespace
1142///   - there is at least one full indent unit of leading whitespace
1143fn try_dedent_close_bracket<H: crate::types::Host>(
1144    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1145    cursor: hjkl_buffer::Position,
1146    ch: char,
1147) -> bool {
1148    use hjkl_buffer::{Edit, MotionKind, Position};
1149
1150    if !ed.settings.smartindent {
1151        return false;
1152    }
1153    if !matches!(ch, '}' | ')' | ']') {
1154        return false;
1155    }
1156
1157    let line = match buf_line(&ed.buffer, cursor.row) {
1158        Some(l) => l.to_string(),
1159        None => return false,
1160    };
1161
1162    // All chars before cursor must be whitespace.
1163    let before: String = line.chars().take(cursor.col).collect();
1164    if !before.chars().all(|c| c == ' ' || c == '\t') {
1165        return false;
1166    }
1167    if before.is_empty() {
1168        // Nothing to strip — just insert normally (cursor at col 0).
1169        return false;
1170    }
1171
1172    // Compute indent unit.
1173    let unit_len: usize = if ed.settings.expandtab {
1174        if ed.settings.softtabstop > 0 {
1175            ed.settings.softtabstop
1176        } else {
1177            ed.settings.shiftwidth
1178        }
1179    } else {
1180        // Tab: one literal tab character.
1181        1
1182    };
1183
1184    // Check there's at least one full unit to strip.
1185    let strip_len = if ed.settings.expandtab {
1186        // Count leading spaces; need at least `unit_len`.
1187        let spaces = before.chars().filter(|c| *c == ' ').count();
1188        if spaces < unit_len {
1189            return false;
1190        }
1191        unit_len
1192    } else {
1193        // noexpandtab: strip one leading tab.
1194        if !before.starts_with('\t') {
1195            return false;
1196        }
1197        1
1198    };
1199
1200    // Delete the leading `strip_len` chars of the current line.
1201    ed.mutate_edit(Edit::DeleteRange {
1202        start: Position::new(cursor.row, 0),
1203        end: Position::new(cursor.row, strip_len),
1204        kind: MotionKind::Char,
1205    });
1206    // Insert the close bracket at column 0 (after the delete the cursor
1207    // is still positioned at the end of the remaining whitespace; the
1208    // delete moved the text so the cursor is now at col = before.len() -
1209    // strip_len).
1210    let new_col = cursor.col.saturating_sub(strip_len);
1211    ed.mutate_edit(Edit::InsertChar {
1212        at: Position::new(cursor.row, new_col),
1213        ch,
1214    });
1215    true
1216}
1217
1218fn finish_insert_session<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
1219    let Some(session) = ed.vim.insert_session.take() else {
1220        return;
1221    };
1222    let after_rope = crate::types::Query::rope(&ed.buffer);
1223    // Clamp both slices to their respective bounds — the buffer may have
1224    // grown (Enter splits rows) or shrunk (Backspace joins rows) during
1225    // the session, so row_max can overshoot either side.
1226    let before_n = session.before_rope.len_lines();
1227    let after_n = after_rope.len_lines();
1228    let after_end = session.row_max.min(after_n.saturating_sub(1));
1229    let before_end = session.row_max.min(before_n.saturating_sub(1));
1230    let before = if before_end >= session.row_min && session.row_min < before_n {
1231        rope_row_range_str(&session.before_rope, session.row_min, before_end)
1232    } else {
1233        String::new()
1234    };
1235    let after = if after_end >= session.row_min && session.row_min < after_n {
1236        rope_row_range_str(&after_rope, session.row_min, after_end)
1237    } else {
1238        String::new()
1239    };
1240    // `R` overstrike keeps the line length the same, so `extract_inserted`
1241    // (which only reports net growth) misses the typed text. Use the changed
1242    // run instead so dot-repeat retypes it.
1243    let inserted = if matches!(session.reason, InsertReason::Replace) {
1244        changed_run(&before, &after)
1245    } else {
1246        extract_inserted(&before, &after)
1247    };
1248    // vim `".` register — text of the most recent insert.
1249    if !ed.vim.replaying && !inserted.is_empty() {
1250        ed.vim.last_insert_text = Some(inserted.clone());
1251    }
1252    let open_line = matches!(session.reason, InsertReason::Open { .. });
1253    if session.count > 1 && !ed.vim.replaying {
1254        use hjkl_buffer::{Edit, Position};
1255        if open_line {
1256            // `[count]o` / `[count]O` open `count` SEPARATE lines, each with the
1257            // typed text. Read the just-opened line's content directly (the
1258            // row-range extract above is unreliable across the open boundary)
1259            // and stack `count - 1` further lines below it.
1260            let (start_row, _) = ed.cursor();
1261            let typed = buf_line(&ed.buffer, start_row).unwrap_or_default();
1262            for at_row in start_row..start_row + (session.count - 1) {
1263                let end = buf_line_chars(&ed.buffer, at_row);
1264                ed.mutate_edit(Edit::InsertStr {
1265                    at: Position::new(at_row, end),
1266                    text: format!("\n{typed}"),
1267                });
1268            }
1269        } else if !inserted.is_empty() {
1270            // `[count]i` / `[count]A` repeat the typed text inline.
1271            for _ in 0..session.count - 1 {
1272                let (row, col) = ed.cursor();
1273                ed.mutate_edit(Edit::InsertStr {
1274                    at: Position::new(row, col),
1275                    text: inserted.clone(),
1276                });
1277            }
1278        }
1279    }
1280    // Helper: replicate `inserted` text across block rows top+1..=bot at `col`,
1281    // padding short rows to reach `col` first. Returns without touching the
1282    // cursor — callers position the cursor afterward according to their needs.
1283    fn replicate_block_text<H: crate::types::Host>(
1284        ed: &mut Editor<hjkl_buffer::Buffer, H>,
1285        inserted: &str,
1286        top: usize,
1287        bot: usize,
1288        col: usize,
1289    ) {
1290        use hjkl_buffer::{Edit, Position};
1291        for r in (top + 1)..=bot {
1292            let line_len = buf_line_chars(&ed.buffer, r);
1293            if col > line_len {
1294                let pad: String = std::iter::repeat_n(' ', col - line_len).collect();
1295                ed.mutate_edit(Edit::InsertStr {
1296                    at: Position::new(r, line_len),
1297                    text: pad,
1298                });
1299            }
1300            ed.mutate_edit(Edit::InsertStr {
1301                at: Position::new(r, col),
1302                text: inserted.to_string(),
1303            });
1304        }
1305    }
1306
1307    if let InsertReason::BlockEdge { top, bot, col } = session.reason {
1308        // `I` / `A` from VisualBlock: replicate text across rows; cursor
1309        // stays at the block-start column (vim leaves cursor there).
1310        if !inserted.is_empty() && top < bot && !ed.vim.replaying {
1311            replicate_block_text(ed, &inserted, top, bot, col);
1312            buf_set_cursor_rc(&mut ed.buffer, top, col);
1313            ed.push_buffer_cursor_to_textarea();
1314        }
1315        return;
1316    }
1317    if let InsertReason::BlockChange { top, bot, col } = session.reason {
1318        // `c` from VisualBlock: replicate text across rows; cursor advances
1319        // to `col + ins_chars` (pre-step-back) so the Esc step-back lands
1320        // on the last typed char (col + ins_chars - 1), matching nvim.
1321        if !inserted.is_empty() && top < bot && !ed.vim.replaying {
1322            replicate_block_text(ed, &inserted, top, bot, col);
1323            let ins_chars = inserted.chars().count();
1324            let line_len = buf_line_chars(&ed.buffer, top);
1325            let target_col = (col + ins_chars).min(line_len);
1326            buf_set_cursor_rc(&mut ed.buffer, top, target_col);
1327            ed.push_buffer_cursor_to_textarea();
1328        }
1329        return;
1330    }
1331    if ed.vim.replaying {
1332        return;
1333    }
1334    match session.reason {
1335        InsertReason::Enter(entry) => {
1336            ed.vim.last_change = Some(LastChange::InsertAt {
1337                entry,
1338                inserted,
1339                count: session.count,
1340            });
1341        }
1342        InsertReason::Open { above } => {
1343            ed.vim.last_change = Some(LastChange::OpenLine { above, inserted });
1344        }
1345        InsertReason::AfterChange => {
1346            if let Some(
1347                LastChange::OpMotion { inserted: ins, .. }
1348                | LastChange::OpTextObj { inserted: ins, .. }
1349                | LastChange::LineOp { inserted: ins, .. }
1350                | LastChange::GnOp { inserted: ins, .. },
1351            ) = ed.vim.last_change.as_mut()
1352            {
1353                *ins = Some(inserted);
1354            }
1355            // Vim `:h '[` / `:h ']`: on change, `[` = start of the
1356            // changed range (stashed before the cut), `]` = the cursor
1357            // at Esc time (last inserted char, before the step-back).
1358            // When nothing was typed cursor still sits at the change
1359            // start, satisfying vim's "both at start" parity for `c<m><Esc>`.
1360            if let Some(start) = ed.vim.change_mark_start.take() {
1361                let end = ed.cursor();
1362                ed.set_mark('[', start);
1363                ed.set_mark(']', end);
1364            }
1365        }
1366        InsertReason::DeleteToEol => {
1367            ed.vim.last_change = Some(LastChange::DeleteToEol {
1368                inserted: Some(inserted),
1369            });
1370        }
1371        InsertReason::ReplayOnly => {}
1372        InsertReason::BlockEdge { .. } => unreachable!("handled above"),
1373        InsertReason::BlockChange { .. } => unreachable!("handled above"),
1374        InsertReason::Replace => {
1375            // `R` overstrike: dot-repeat re-overtypes the same text at the
1376            // cursor (vim parity — not a delete-to-EOL).
1377            ed.vim.last_change = Some(LastChange::ReplaceMode { text: inserted });
1378        }
1379    }
1380}
1381
1382pub(crate) fn begin_insert<H: crate::types::Host>(
1383    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1384    count: usize,
1385    reason: InsertReason,
1386) {
1387    // `nomodifiable`: silently refuse to enter insert/replace; stay in current mode.
1388    if !ed.settings.modifiable {
1389        return;
1390    }
1391    // BLAME view: pressing `i` exits blame (drops the overlay) but stays Normal.
1392    if ed.vim.view == crate::ViewMode::Blame {
1393        ed.vim.view = crate::ViewMode::Normal;
1394        return;
1395    }
1396    let record = !matches!(reason, InsertReason::ReplayOnly);
1397    if record {
1398        ed.push_undo();
1399    }
1400    let reason = if ed.vim.replaying {
1401        InsertReason::ReplayOnly
1402    } else {
1403        reason
1404    };
1405    let (row, col) = ed.cursor();
1406    ed.vim.insert_session = Some(InsertSession {
1407        count,
1408        row_min: row,
1409        row_max: row,
1410        before_rope: crate::types::Query::rope(&ed.buffer),
1411        reason,
1412        start_row: row,
1413        start_col: col,
1414    });
1415    ed.vim.mode = Mode::Insert;
1416    // Phase 6.3: keep current_mode in sync for callers that bypass step().
1417    ed.vim.current_mode = crate::VimMode::Insert;
1418    drop_blame_if_left_normal(ed);
1419}
1420
1421/// `:set undobreak` semantics for insert-mode motions. When the
1422/// toggle is on, a non-character keystroke that moves the cursor
1423/// (arrow keys, Home/End, mouse click) ends the current undo group
1424/// and starts a new one mid-session. After this, a subsequent `u`
1425/// in normal mode reverts only the post-break run, leaving the
1426/// pre-break edits in place — matching vim's behaviour.
1427///
1428/// Implementation: snapshot the current buffer onto the undo stack
1429/// (the new break point) and reset the active `InsertSession`'s
1430/// `before_lines` so `finish_insert_session`'s diff window only
1431/// captures the post-break run for `last_change` / dot-repeat.
1432///
1433/// During replay we skip the break — replay shouldn't pollute the
1434/// undo stack with intra-replay snapshots.
1435pub(crate) fn break_undo_group_in_insert<H: crate::types::Host>(
1436    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1437) {
1438    if !ed.settings.undo_break_on_motion {
1439        return;
1440    }
1441    if ed.vim.replaying {
1442        return;
1443    }
1444    if ed.vim.insert_session.is_none() {
1445        return;
1446    }
1447    ed.push_undo();
1448    let before_rope = crate::types::Query::rope(&ed.buffer);
1449    let row = crate::types::Cursor::cursor(&ed.buffer).line as usize;
1450    if let Some(ref mut session) = ed.vim.insert_session {
1451        session.before_rope = before_rope;
1452        session.row_min = row;
1453        session.row_max = row;
1454    }
1455}
1456
1457// ─── Phase 6.1: public insert-mode primitives ──────────────────────────────
1458//
1459// Each `pub(crate)` free function below implements one insert-mode action.
1460// hjkl-vim's insert dispatcher calls them through `Editor::insert_*` methods.
1461// External callers can also invoke the public Editor methods directly.
1462//
1463// Invariants every function upholds:
1464//   - Opens with `ed.sync_buffer_content_from_textarea()` (no-op, kept for
1465//     forward compatibility once textarea is gone).
1466//   - All buffer mutations go through `ed.mutate_edit(...)` so dirty flag,
1467//     undo, change-list, content-edit fan-out all fire uniformly.
1468//   - Navigation-only functions call `break_undo_group_in_insert` when the
1469//     FSM did so, then return `false` (no mutation).
1470//   - After mutations, `ed.push_buffer_cursor_to_textarea()` is called
1471//     (currently a no-op but kept for migration hygiene).
1472//   - Returns `true` when the buffer was mutated, `false` otherwise.
1473
1474/// Return the filetype-gated autopair close character for `open`, or `None`
1475/// when no pairing applies.
1476///
1477/// Rules:
1478/// - `(` → `)`, `[` → `]`, `{` → `}` always.
1479/// - `"` → `"` and `` ` `` → `` ` `` always, EXCEPT when the previous two
1480///   characters are the same quote — typing the third `` ` `` of a markdown
1481///   code-fence or the third `"` of a Python triple-quoted string must
1482///   emit a bare quote (no close) so the result is `` ``` `` / `"""` and
1483///   not `` ```` `` / `""""`.
1484/// - `<` → `>` only for HTML/XML family filetypes.
1485/// - `'` → `'` unless the character immediately before the cursor is
1486///   `[A-Za-z]` (prose apostrophe guard — "don't" stays "don't"), AND the
1487///   same triple-quote guard as `"` / `` ` ``.
1488fn autopair_close_for(
1489    ch: char,
1490    filetype: &str,
1491    prev_char: Option<char>,
1492    prev2_char: Option<char>,
1493) -> Option<char> {
1494    // Triple-quote guard — applies to ", `, and ' (the three quote chars
1495    // that get same-char pairing). When the previous two characters are
1496    // both this same quote, treat the third keystroke as a bare insert so
1497    // the user lands on `` ``` `` / `"""` / `'''` without a stray fourth
1498    // quote dangling after the cursor.
1499    let is_triple_quote_third =
1500        matches!(ch, '"' | '`' | '\'') && prev_char == Some(ch) && prev2_char == Some(ch);
1501
1502    match ch {
1503        '(' => Some(')'),
1504        '[' => Some(']'),
1505        '{' => Some('}'),
1506        '"' => {
1507            if is_triple_quote_third {
1508                None
1509            } else {
1510                Some('"')
1511            }
1512        }
1513        '`' => {
1514            if is_triple_quote_third {
1515                None
1516            } else {
1517                Some('`')
1518            }
1519        }
1520        '<' => {
1521            if is_html_filetype(filetype) {
1522                Some('>')
1523            } else {
1524                None
1525            }
1526        }
1527        '\'' => {
1528            if is_triple_quote_third {
1529                return None;
1530            }
1531            // Prose guard: skip pairing when the previous char is a letter
1532            // (covers "don't", "it's", etc.).
1533            if prev_char.map(|c| c.is_ascii_alphabetic()).unwrap_or(false) {
1534                None
1535            } else {
1536                Some('\'')
1537            }
1538        }
1539        _ => None,
1540    }
1541}
1542
1543/// Detect a markdown / doc-comment code-fence opener on the current line.
1544///
1545/// Returns `Some(fence)` (the backtick run that should be used as the
1546/// closing fence) when:
1547/// - The cursor is at the end of the visible line (`cursor_col` equals the
1548///   line's char count).
1549/// - The line, after leading whitespace, begins with 3+ backticks followed
1550///   by a non-empty language tag matching `[A-Za-z0-9_+-]+` and nothing
1551///   else (no trailing space, no extra text).
1552///
1553/// The language tag requirement is deliberate: a bare ` ``` ` could be
1554/// either an opener OR a closer, and we don't track fence parity here.
1555/// Requiring a tag means we only fire when the user is clearly opening a
1556/// fence (` ```rust `, ` ```ts `, etc.).
1557fn detect_code_fence_opener(line: &str, cursor_col: usize) -> Option<String> {
1558    if cursor_col != line.chars().count() {
1559        return None;
1560    }
1561    let trimmed = line.trim_start();
1562    let backtick_run = trimmed.chars().take_while(|c| *c == '`').count();
1563    if backtick_run < 3 {
1564        return None;
1565    }
1566    let rest = &trimmed[backtick_run..];
1567    if rest.is_empty() {
1568        return None;
1569    }
1570    let all_lang_chars = rest
1571        .chars()
1572        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '+' || c == '-');
1573    if !all_lang_chars {
1574        return None;
1575    }
1576    Some("`".repeat(backtick_run))
1577}
1578
1579/// Filetypes that get HTML/XML-family treatment (`<` pairing + tag autoclose).
1580fn is_html_filetype(ft: &str) -> bool {
1581    matches!(
1582        ft,
1583        "html" | "xml" | "svg" | "jsx" | "tsx" | "vue" | "svelte"
1584    )
1585}
1586
1587// ── Paired-tag auto-rename (issue #182) ────────────────────────────────────
1588//
1589// When the user edits the name of an HTML/XML opening tag (e.g. `ci<` to
1590// change-inner the tag name, type a new name, then `<Esc>`), the matching
1591// closing tag should rename automatically so the pair stays in sync.
1592// Same on the close side: edit `</X>` → its opener gets renamed.
1593//
1594// Trigger: leave_insert_to_normal_bridge calls sync_paired_tag_on_exit, which
1595// inspects the cursor's current position. If the cursor sits inside a tag
1596// name and the paired tag has a different name, rewrite the paired tag.
1597//
1598// Pairing uses a stack-based scan so nested same-name tags
1599// (`<div><div></div></div>`) pair correctly.
1600
1601/// Tag kind detected at a cursor position.
1602#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1603enum TagKind {
1604    Open,
1605    Close,
1606}
1607
1608/// A single tag instance located in the buffer.
1609#[derive(Debug, Clone, PartialEq, Eq)]
1610struct TagSpan {
1611    kind: TagKind,
1612    name: String,
1613    /// Row index in the buffer.
1614    row: usize,
1615    /// Char-column range of the tag NAME (excluding `<`, `</`, attributes, `>`).
1616    name_start_col: usize,
1617    name_end_col: usize,
1618}
1619
1620/// Detect the tag containing `(row, col)` in `line`. Returns the tag kind
1621/// (Open / Close), its name, and the char-column range of that name.
1622/// Returns `None` when the cursor is not inside a tag-name region.
1623fn detect_tag_at_cursor(line: &str, row: usize, col: usize) -> Option<TagSpan> {
1624    let chars: Vec<char> = line.chars().collect();
1625    // Find the nearest `<` at or before the cursor column.
1626    let mut lt = None;
1627    let mut i = col.min(chars.len());
1628    while i > 0 {
1629        i -= 1;
1630        let c = chars[i];
1631        if c == '<' {
1632            lt = Some(i);
1633            break;
1634        }
1635        // Bail if we cross a `>` (we're outside any open tag).
1636        if c == '>' {
1637            return None;
1638        }
1639    }
1640    let lt = lt?;
1641    // Detect close tag (`</`) vs open (`<`).
1642    let (kind, name_start) = if chars.get(lt + 1) == Some(&'/') {
1643        (TagKind::Close, lt + 2)
1644    } else {
1645        (TagKind::Open, lt + 1)
1646    };
1647    // First char of the name must be a letter.
1648    let first = chars.get(name_start)?;
1649    if !first.is_ascii_alphabetic() {
1650        return None;
1651    }
1652    // Tag name = [A-Za-z][A-Za-z0-9-]*
1653    let mut name_end = name_start;
1654    while name_end < chars.len()
1655        && (chars[name_end].is_ascii_alphanumeric() || chars[name_end] == '-')
1656    {
1657        name_end += 1;
1658    }
1659    // Cursor must be inside the name range (inclusive of both ends so that
1660    // landing right after the name still resolves — vim Insert leaves the
1661    // cursor one past the last typed char).
1662    if col < name_start || col > name_end {
1663        return None;
1664    }
1665    let name: String = chars[name_start..name_end].iter().collect();
1666    Some(TagSpan {
1667        kind,
1668        name,
1669        row,
1670        name_start_col: name_start,
1671        name_end_col: name_end,
1672    })
1673}
1674
1675/// Scan the buffer to find the structural partner of `anchor` using a
1676/// depth counter. Names are intentionally NOT compared during the scan —
1677/// the anchor is the source of truth and the partner inherits its name.
1678/// Otherwise an in-flight rename (the whole point of this feature) would
1679/// look like a malformed pair and bail.
1680///
1681/// Forward scan from an opener: opens increment depth, closes decrement
1682/// depth. The close that brings depth back to zero is the partner.
1683/// Backward scan from a closer is symmetric (closes increment, opens
1684/// decrement).
1685///
1686/// Returns `None` when the buffer end is reached before depth hits zero
1687/// (orphan tag or malformed input).
1688fn find_matching_tag(buffer: &hjkl_buffer::Buffer, anchor: &TagSpan) -> Option<TagSpan> {
1689    let row_count = buffer.row_count();
1690    let scan_forward = anchor.kind == TagKind::Open;
1691    let row_iter: Box<dyn Iterator<Item = usize>> = if scan_forward {
1692        Box::new(anchor.row..row_count)
1693    } else {
1694        Box::new((0..=anchor.row).rev())
1695    };
1696    let push_kind = if scan_forward {
1697        TagKind::Open
1698    } else {
1699        TagKind::Close
1700    };
1701    let mut depth: usize = 1;
1702
1703    for r in row_iter {
1704        let line = buf_line(buffer, r)?;
1705        let chars: Vec<char> = line.chars().collect();
1706        let tags = scan_line_tags(&chars, r);
1707        let tags_iter: Box<dyn Iterator<Item = TagSpan>> = if scan_forward {
1708            Box::new(tags.into_iter())
1709        } else {
1710            Box::new(tags.into_iter().rev())
1711        };
1712        for tag in tags_iter {
1713            // Skip the anchor itself when we walk over its line.
1714            if r == anchor.row
1715                && tag.name_start_col == anchor.name_start_col
1716                && tag.kind == anchor.kind
1717            {
1718                continue;
1719            }
1720            // On the anchor's own row, gate by direction relative to anchor
1721            // so the scan only inspects tags AFTER the anchor (forward) or
1722            // BEFORE the anchor (backward).
1723            if r == anchor.row {
1724                if scan_forward && tag.name_start_col < anchor.name_start_col {
1725                    continue;
1726                }
1727                if !scan_forward && tag.name_start_col > anchor.name_start_col {
1728                    continue;
1729                }
1730            }
1731            if tag.kind == push_kind {
1732                depth += 1;
1733            } else {
1734                depth -= 1;
1735                if depth == 0 {
1736                    return Some(tag);
1737                }
1738            }
1739        }
1740    }
1741    None
1742}
1743
1744/// Collect all tag opens / closes on a single line in left-to-right order.
1745/// Skips comments (`<!-- ... -->`) and self-closing tags (`<br />`), and
1746/// excludes void HTML elements that don't form a pair.
1747fn scan_line_tags(chars: &[char], row: usize) -> Vec<TagSpan> {
1748    let mut out = Vec::new();
1749    let n = chars.len();
1750    let mut i = 0;
1751    while i < n {
1752        if chars[i] != '<' {
1753            i += 1;
1754            continue;
1755        }
1756        // `<!--` comment — skip to `-->`.
1757        if chars[i..].starts_with(&['<', '!', '-', '-']) {
1758            let mut j = i + 4;
1759            while j + 2 < n && !(chars[j] == '-' && chars[j + 1] == '-' && chars[j + 2] == '>') {
1760                j += 1;
1761            }
1762            i = (j + 3).min(n);
1763            continue;
1764        }
1765        let (kind, name_start) = if chars.get(i + 1) == Some(&'/') {
1766            (TagKind::Close, i + 2)
1767        } else {
1768            (TagKind::Open, i + 1)
1769        };
1770        // Validate name start.
1771        if chars
1772            .get(name_start)
1773            .is_none_or(|c| !c.is_ascii_alphabetic())
1774        {
1775            i += 1;
1776            continue;
1777        }
1778        let mut name_end = name_start;
1779        while name_end < n && (chars[name_end].is_ascii_alphanumeric() || chars[name_end] == '-') {
1780            name_end += 1;
1781        }
1782        // Find the closing `>` to know whether this tag is self-closing.
1783        let mut k = name_end;
1784        let mut self_closing = false;
1785        while k < n {
1786            if chars[k] == '>' {
1787                if k > name_end && chars[k - 1] == '/' {
1788                    self_closing = true;
1789                }
1790                break;
1791            }
1792            k += 1;
1793        }
1794        if k >= n {
1795            // Unterminated tag on this line — bail.
1796            break;
1797        }
1798        let name: String = chars[name_start..name_end].iter().collect();
1799        // Skip self-closing and void elements (no pair).
1800        if !(self_closing || kind == TagKind::Open && is_void_element(&name)) {
1801            out.push(TagSpan {
1802                kind,
1803                name,
1804                row,
1805                name_start_col: name_start,
1806                name_end_col: name_end,
1807            });
1808        }
1809        i = k + 1;
1810    }
1811    out
1812}
1813
1814/// If the cursor sits inside an HTML/XML tag name AND the paired tag's name
1815/// differs, rewrite the paired tag's name to match. Called from
1816/// `leave_insert_to_normal_bridge` so the magical sync fires exactly when
1817/// the user finishes editing.
1818pub(crate) fn sync_paired_tag_on_exit<H: crate::types::Host>(
1819    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1820) {
1821    if !is_html_filetype(&ed.settings.filetype) {
1822        return;
1823    }
1824    let (row, col) = ed.cursor();
1825    let line = match buf_line(&ed.buffer, row) {
1826        Some(l) => l,
1827        None => return,
1828    };
1829    let anchor = match detect_tag_at_cursor(&line, row, col) {
1830        Some(t) => t,
1831        None => return,
1832    };
1833    let partner = match find_matching_tag(&ed.buffer, &anchor) {
1834        Some(t) => t,
1835        None => return,
1836    };
1837    if partner.name == anchor.name {
1838        return;
1839    }
1840    // Rewrite the partner's name range with the anchor's name.
1841    use hjkl_buffer::{Edit, MotionKind, Position};
1842    let start = Position::new(partner.row, partner.name_start_col);
1843    let end = Position::new(partner.row, partner.name_end_col);
1844    ed.mutate_edit(Edit::DeleteRange {
1845        start,
1846        end,
1847        kind: MotionKind::Char,
1848    });
1849    ed.mutate_edit(Edit::InsertStr {
1850        at: start,
1851        text: anchor.name.clone(),
1852    });
1853    // Restore the user's cursor — mutate_edit may have moved it during the
1854    // partner-side rewrite when the partner is on a row before the cursor.
1855    buf_set_cursor_rc(&mut ed.buffer, row, col);
1856    ed.push_buffer_cursor_to_textarea();
1857}
1858
1859/// Resolve the HTML/XML tag-name pair under the cursor for matchparen-style
1860/// highlight (#243). Returns `[(row, name_start_col, name_end_col); 2]` for
1861/// the tag under the cursor and its structural partner, or `None` when the
1862/// cursor is not on a tag name or the tag is unpaired. Char-column ranges
1863/// (display), consistent with `motions::matching_bracket_pos`.
1864pub fn matching_tag_pair(
1865    buffer: &hjkl_buffer::Buffer,
1866    row: usize,
1867    col: usize,
1868) -> Option<[(usize, usize, usize); 2]> {
1869    let line = buf_line(buffer, row)?;
1870    let anchor = detect_tag_at_cursor(&line, row, col)?;
1871    let partner = find_matching_tag(buffer, &anchor)?;
1872    Some([
1873        (anchor.row, anchor.name_start_col, anchor.name_end_col),
1874        (partner.row, partner.name_start_col, partner.name_end_col),
1875    ])
1876}
1877
1878/// Void HTML elements that must never get an auto-close tag.
1879fn is_void_element(tag: &str) -> bool {
1880    matches!(
1881        tag.to_ascii_lowercase().as_str(),
1882        "area"
1883            | "base"
1884            | "br"
1885            | "col"
1886            | "embed"
1887            | "hr"
1888            | "img"
1889            | "input"
1890            | "link"
1891            | "meta"
1892            | "param"
1893            | "source"
1894            | "track"
1895            | "wbr"
1896    )
1897}
1898
1899/// Scan backward from `col` (exclusive) in `line` for a `<tagname…` opener.
1900///
1901/// Returns `Some(tag_name)` when:
1902/// - An opening `<` is found
1903/// - The tag name matches `[A-Za-z][A-Za-z0-9-]*`
1904/// - The tag is not self-closing (does not end with `/` before `>`)
1905/// - The tag is not a void element
1906///
1907/// Returns `None` otherwise (no opener, self-closing, void, or malformed).
1908fn scan_tag_opener(line: &str, col: usize) -> Option<String> {
1909    // col is where `>` was just inserted (the char is already in the line).
1910    // We look at the slice BEFORE the `>`.
1911    let before = if col > 0 { &line[..col] } else { return None };
1912
1913    // Walk backward to find the matching `<`.
1914    let lt_pos = before.rfind('<')?;
1915    let inner = &before[lt_pos + 1..]; // e.g. "div class=\"foo\""
1916
1917    // A `!` opener is a comment/doctype — skip.
1918    if inner.starts_with('!') {
1919        return None;
1920    }
1921    // Self-closing if the last non-space char before `>` was `/`.
1922    if inner.trim_end().ends_with('/') {
1923        return None;
1924    }
1925
1926    // Extract tag name: first token of `inner`.
1927    let tag: String = inner
1928        .chars()
1929        .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
1930        .collect();
1931    if tag.is_empty() {
1932        return None;
1933    }
1934    // First char must be a letter.
1935    if !tag
1936        .chars()
1937        .next()
1938        .map(|c| c.is_ascii_alphabetic())
1939        .unwrap_or(false)
1940    {
1941        return None;
1942    }
1943    if is_void_element(&tag) {
1944        return None;
1945    }
1946    Some(tag)
1947}
1948
1949/// Insert a single character at the cursor. Handles replace-mode overstrike
1950/// (when `InsertSession::reason` is `Replace`) and smart-indent dedent of
1951/// closing brackets (}/)]/). Also handles autopair insertion and skip-over.
1952/// Returns `true`.
1953pub(crate) fn insert_char_bridge<H: crate::types::Host>(
1954    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1955    ch: char,
1956) -> bool {
1957    use hjkl_buffer::{Edit, MotionKind, Position};
1958    ed.sync_buffer_content_from_textarea();
1959    let in_replace = matches!(
1960        ed.vim.insert_session.as_ref().map(|s| &s.reason),
1961        Some(InsertReason::Replace)
1962    );
1963
1964    // ── Abbreviation expansion (insert mode, non-replace) ────────────────────
1965    // A non-keyword char typed in insert mode can trigger expansion.
1966    // We check BEFORE inserting the character; if an abbrev matches, we delete
1967    // the lhs and insert the rhs, then continue to insert `ch` as normal.
1968    // `<C-v>` (literal-insert) must bypass this — callers that want literal
1969    // insertion should NOT call this bridge; they use insert_char_literal.
1970    if !in_replace && !ed.vim.abbrevs.is_empty() {
1971        let iskeyword = ed.settings.iskeyword.clone();
1972        if !is_keyword_char(ch, &iskeyword) {
1973            // Only non-keyword trigger chars fire abbreviation expansion.
1974            check_and_apply_abbrev(ed, AbbrevTrigger::NonKeyword(ch));
1975            // (we do NOT return early; continue to insert `ch` below)
1976        }
1977    }
1978    // Read cursor (after any abbreviation expansion that may have changed the buffer).
1979    let cursor = buf_cursor_pos(&ed.buffer);
1980    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
1981
1982    // ── Skip-over: if the typed char matches the top of the pending-closes
1983    // stack AND the char currently under the cursor IS that close char,
1984    // pop the stack and advance the cursor instead of inserting.
1985    //
1986    // We check the actual char in the buffer (not a stored col) so that
1987    // characters typed between the pair don't invalidate the skip — the
1988    // close char shifts right as the user types inside, but the buffer
1989    // char check always finds it correctly.
1990    if !in_replace
1991        && !ed.vim.pending_closes.is_empty()
1992        && let Some(&(pr, _pc, pch)) = ed.vim.pending_closes.last()
1993        && ch == pch
1994        && cursor.row == pr
1995    {
1996        let char_at_cursor =
1997            buf_line(&ed.buffer, cursor.row).and_then(|l| l.chars().nth(cursor.col));
1998        if char_at_cursor == Some(ch) {
1999            ed.vim.pending_closes.pop();
2000            // For `>` skip-over in HTML/XML: also run tag autoclose.
2001            let filetype = ed.settings.filetype.clone();
2002            let autoclose_tag = ed.settings.autoclose_tag;
2003            if ch == '>' && autoclose_tag && is_html_filetype(&filetype) {
2004                // Skip past the `>` that was auto-inserted.
2005                let new_col = cursor.col + 1;
2006                buf_set_cursor_rc(&mut ed.buffer, cursor.row, new_col);
2007                // Now check for tag autoclose on the line up to new_col.
2008                // `new_col.saturating_sub(1)` is a char index; convert to a
2009                // byte offset before slicing in scan_tag_opener.
2010                if let Some(line) = buf_line(&ed.buffer, cursor.row) {
2011                    let char_col = new_col.saturating_sub(1);
2012                    let byte_col = line
2013                        .char_indices()
2014                        .nth(char_col)
2015                        .map(|(b, _)| b)
2016                        .unwrap_or(line.len());
2017                    if let Some(tag) = scan_tag_opener(&line, byte_col) {
2018                        let close_tag = format!("</{tag}>");
2019                        let insert_pos = Position::new(cursor.row, new_col);
2020                        ed.mutate_edit(Edit::InsertStr {
2021                            at: insert_pos,
2022                            text: close_tag,
2023                        });
2024                        // Cursor stays at new_col (between > and </tag>).
2025                        buf_set_cursor_rc(&mut ed.buffer, cursor.row, new_col);
2026                    }
2027                }
2028            } else {
2029                buf_set_cursor_rc(&mut ed.buffer, cursor.row, cursor.col + 1);
2030            }
2031            ed.push_buffer_cursor_to_textarea();
2032            return true;
2033        }
2034    }
2035
2036    if in_replace && cursor.col < line_chars {
2037        // Replace mode: clear pending closes (edit outside the pair).
2038        ed.vim.pending_closes.clear();
2039        ed.mutate_edit(Edit::DeleteRange {
2040            start: cursor,
2041            end: Position::new(cursor.row, cursor.col + 1),
2042            kind: MotionKind::Char,
2043        });
2044        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
2045    } else if !try_dedent_close_bracket(ed, cursor, ch) {
2046        // Normal insert. Check autopair first.
2047        let autopair = ed.settings.autopair;
2048        let filetype = ed.settings.filetype.clone();
2049        let autoclose_tag = ed.settings.autoclose_tag;
2050
2051        let (prev_char, prev2_char) = {
2052            let line = buf_line(&ed.buffer, cursor.row).unwrap_or_default();
2053            let chars: Vec<char> = line.chars().collect();
2054            let p1 = if cursor.col > 0 {
2055                chars.get(cursor.col - 1).copied()
2056            } else {
2057                None
2058            };
2059            let p2 = if cursor.col > 1 {
2060                chars.get(cursor.col - 2).copied()
2061            } else {
2062                None
2063            };
2064            (p1, p2)
2065        };
2066
2067        if autopair {
2068            if let Some(close) = autopair_close_for(ch, &filetype, prev_char, prev2_char) {
2069                // Insert open char.
2070                ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
2071                // Insert close char immediately after the open char.
2072                // After inserting open at cursor, buffer cursor is at cursor.col+1.
2073                let after = Position::new(cursor.row, cursor.col + 1);
2074                ed.mutate_edit(Edit::InsertChar {
2075                    at: after,
2076                    ch: close,
2077                });
2078                // After inserting close, buffer cursor is at cursor.col+2.
2079                // We want cursor between open and close: cursor.col+1.
2080                let between_col = cursor.col + 1;
2081                buf_set_cursor_rc(&mut ed.buffer, cursor.row, between_col);
2082                // Record the close char for skip-over. We store the row and
2083                // the close char; col is not tracked precisely because chars
2084                // typed inside the pair shift the close right. The skip-over
2085                // logic checks the actual buffer char at cursor instead.
2086                ed.vim.pending_closes.push((cursor.row, between_col, close));
2087                ed.push_buffer_cursor_to_textarea();
2088                return true;
2089            }
2090
2091            // Tag autoclose: `>` in HTML/XML family (no prior `<` pair).
2092            // This fires when autopair did NOT match `>` (e.g. `>` was
2093            // typed directly, not via a skip-over of an auto-inserted `>`).
2094            if ch == '>' && autoclose_tag && is_html_filetype(&filetype) {
2095                ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
2096                let new_col = cursor.col + 1;
2097                // scan_tag_opener looks at the line up to (new_col-1), i.e.
2098                // the char just inserted is at index new_col-1.
2099                // `new_col.saturating_sub(1)` is a char index; convert to a
2100                // byte offset before slicing in scan_tag_opener.
2101                if let Some(line) = buf_line(&ed.buffer, cursor.row) {
2102                    let char_col = new_col.saturating_sub(1);
2103                    let byte_col = line
2104                        .char_indices()
2105                        .nth(char_col)
2106                        .map(|(b, _)| b)
2107                        .unwrap_or(line.len());
2108                    if let Some(tag) = scan_tag_opener(&line, byte_col) {
2109                        let close_tag = format!("</{tag}>");
2110                        let insert_pos = Position::new(cursor.row, new_col);
2111                        ed.mutate_edit(Edit::InsertStr {
2112                            at: insert_pos,
2113                            text: close_tag,
2114                        });
2115                        // Cursor stays at new_col (between `>` and `</tag>`).
2116                        buf_set_cursor_rc(&mut ed.buffer, cursor.row, new_col);
2117                    }
2118                }
2119                ed.push_buffer_cursor_to_textarea();
2120                return true;
2121            }
2122        }
2123
2124        // Plain insert — do not clear the pending-closes stack here.
2125        // The stack is cleared on cursor motion or mode change (Esc).
2126        // Clearing here would prevent skip-over from firing after the
2127        // user types content inside an auto-paired bracket.
2128        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
2129    }
2130    ed.push_buffer_cursor_to_textarea();
2131    true
2132}
2133
2134/// Insert a newline at the cursor, applying autoindent / smartindent and
2135/// optionally continuing a line comment when `formatoptions` has `r`.
2136/// Also handles open-pair-newline: Enter between `{|}` / `(|)` / `[|]`
2137/// produces an indented block with the close on its own line.
2138/// Returns `true`.
2139pub(crate) fn insert_newline_bridge<H: crate::types::Host>(
2140    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2141) -> bool {
2142    use hjkl_buffer::Edit;
2143    ed.sync_buffer_content_from_textarea();
2144
2145    // ── Abbreviation expansion on CR ─────────────────────────────────────────
2146    // CR triggers expansion for full-id / end-id / non-id abbreviations.
2147    // We expand BEFORE the newline is inserted; CR is then inserted as normal.
2148    if !ed.vim.abbrevs.is_empty() {
2149        check_and_apply_abbrev(ed, AbbrevTrigger::Cr);
2150    }
2151
2152    let cursor = buf_cursor_pos(&ed.buffer);
2153    let prev_line = buf_line(&ed.buffer, cursor.row)
2154        .unwrap_or_default()
2155        .to_string();
2156
2157    // Open-pair-newline: if autopair is on and the cursor is between a
2158    // matching open/close bracket pair, split into two newlines so the
2159    // close ends up on its own dedented line.
2160    if ed.settings.autopair && !ed.vim.pending_closes.is_empty() {
2161        // Check: char before cursor is an open bracket AND char at cursor
2162        // is the matching close bracket (from our pending-closes stack).
2163        let prev_char = if cursor.col > 0 {
2164            prev_line.chars().nth(cursor.col - 1)
2165        } else {
2166            None
2167        };
2168        let next_char = prev_line.chars().nth(cursor.col);
2169        let is_open_pair = matches!(
2170            (prev_char, next_char),
2171            (Some('{'), Some('}')) | (Some('('), Some(')')) | (Some('['), Some(']'))
2172        );
2173        if is_open_pair {
2174            // The pending-closes stack refers to the close char at cursor.col.
2175            // We clear it because the newline expansion moves the close.
2176            ed.vim.pending_closes.clear();
2177            // Compute indents: inner gets one extra unit, close gets base.
2178            let base_indent: String = prev_line
2179                .chars()
2180                .take_while(|c| *c == ' ' || *c == '\t')
2181                .collect();
2182            let inner_indent = if ed.settings.expandtab {
2183                let unit = if ed.settings.softtabstop > 0 {
2184                    ed.settings.softtabstop
2185                } else {
2186                    ed.settings.shiftwidth
2187                };
2188                format!("{base_indent}{}", " ".repeat(unit))
2189            } else {
2190                format!("{base_indent}\t")
2191            };
2192            // Insert: \n<inner_indent>\n<base_indent>
2193            // Then cursor lands after the first \n (inside the block).
2194            let text = format!("\n{inner_indent}\n{base_indent}");
2195            ed.mutate_edit(Edit::InsertStr { at: cursor, text });
2196            // Move cursor to end of first new line (inner_indent line).
2197            let new_row = cursor.row + 1;
2198            let new_col = inner_indent.len();
2199            buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
2200            ed.push_buffer_cursor_to_textarea();
2201            return true;
2202        }
2203    }
2204
2205    // Code-fence expansion: line content is ` ``` ` (3+ backticks) followed
2206    // by a non-empty language tag, cursor sits at end of line → insert the
2207    // matching closing fence on the line below and park the cursor on a
2208    // blank middle line. Matches the open-pair-newline shape but for
2209    // markdown / doc-comment code blocks. Gated on a language tag because
2210    // a bare ` ``` ` could just as easily be a closing fence — we'd need
2211    // full document parity tracking to handle that safely, which v1
2212    // doesn't have.
2213    if ed.settings.autopair
2214        && let Some(fence) = detect_code_fence_opener(&prev_line, cursor.col)
2215    {
2216        ed.vim.pending_closes.clear();
2217        let base_indent: String = prev_line
2218            .chars()
2219            .take_while(|c| *c == ' ' || *c == '\t')
2220            .collect();
2221        let text = format!("\n{base_indent}\n{base_indent}{fence}");
2222        ed.mutate_edit(Edit::InsertStr { at: cursor, text });
2223        let new_row = cursor.row + 1;
2224        let new_col = base_indent.chars().count();
2225        buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
2226        ed.push_buffer_cursor_to_textarea();
2227        return true;
2228    }
2229
2230    // formatoptions `r`: continue comment on Enter in insert mode.
2231    let comment_cont = if ed.settings.formatoptions.contains('r') {
2232        continue_comment(&ed.buffer, &ed.settings, cursor.row)
2233    } else {
2234        None
2235    };
2236
2237    // Any Enter clears the pending-closes stack (cursor moved off the pair).
2238    ed.vim.pending_closes.clear();
2239
2240    let text = if let Some(cont) = comment_cont {
2241        // Comment continuation overrides autoindent: the indent is already
2242        // baked into the continuation prefix.
2243        format!("\n{cont}")
2244    } else {
2245        let indent = compute_enter_indent(&ed.settings, &prev_line);
2246        format!("\n{indent}")
2247    };
2248    ed.mutate_edit(Edit::InsertStr { at: cursor, text });
2249    ed.push_buffer_cursor_to_textarea();
2250    true
2251}
2252
2253/// Insert a tab character (or spaces up to the next softtabstop boundary when
2254/// `expandtab` is set). Returns `true`.
2255pub(crate) fn insert_tab_bridge<H: crate::types::Host>(
2256    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2257) -> bool {
2258    use hjkl_buffer::Edit;
2259    ed.sync_buffer_content_from_textarea();
2260    let cursor = buf_cursor_pos(&ed.buffer);
2261    if ed.settings.expandtab {
2262        let sts = ed.settings.softtabstop;
2263        let n = if sts > 0 {
2264            sts - (cursor.col % sts)
2265        } else {
2266            ed.settings.tabstop.max(1)
2267        };
2268        ed.mutate_edit(Edit::InsertStr {
2269            at: cursor,
2270            text: " ".repeat(n),
2271        });
2272    } else {
2273        ed.mutate_edit(Edit::InsertChar {
2274            at: cursor,
2275            ch: '\t',
2276        });
2277    }
2278    ed.push_buffer_cursor_to_textarea();
2279    true
2280}
2281
2282/// Delete the character before the cursor (vim Backspace / `^H`). With
2283/// `softtabstop` active, deletes the entire soft-tab run at an aligned
2284/// boundary. Joins with the previous line when at column 0.
2285///
2286/// **Comment-continuation backspace**: when the current line's entire content
2287/// is the auto-inserted comment prefix (e.g. `// ` with nothing after it),
2288/// a single Backspace removes the whole prefix in one stroke — vim parity.
2289///
2290/// Returns `true` when something was deleted, `false` at the very start of the
2291/// buffer.
2292pub(crate) fn insert_backspace_bridge<H: crate::types::Host>(
2293    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2294) -> bool {
2295    use hjkl_buffer::{Edit, MotionKind, Position};
2296    ed.sync_buffer_content_from_textarea();
2297    let cursor = buf_cursor_pos(&ed.buffer);
2298
2299    // Comment-continuation backspace: if the line is just the prefix (with no
2300    // user content after it), delete the whole prefix in one stroke.
2301    if cursor.col > 0 {
2302        let line = buf_line(&ed.buffer, cursor.row).unwrap_or_default();
2303        if let Some((indent, prefix)) = detect_comment_on_line(&ed.settings.filetype, &line) {
2304            let full_prefix = format!("{indent}{prefix}");
2305            // The cursor must be at the end of (or within) the prefix with no
2306            // additional content after — i.e. the line equals the prefix exactly.
2307            let line_trimmed = line.trim_end_matches(' ');
2308            let prefix_trimmed = full_prefix.trim_end_matches(' ');
2309            if line_trimmed == prefix_trimmed && cursor.col == full_prefix.chars().count() {
2310                // Delete everything from col 0 to cursor.
2311                ed.mutate_edit(Edit::DeleteRange {
2312                    start: Position::new(cursor.row, 0),
2313                    end: cursor,
2314                    kind: MotionKind::Char,
2315                });
2316                ed.push_buffer_cursor_to_textarea();
2317                return true;
2318            }
2319        }
2320    }
2321
2322    let sts = ed.settings.softtabstop;
2323    if sts > 0 && cursor.col >= sts && cursor.col.is_multiple_of(sts) {
2324        let line = buf_line(&ed.buffer, cursor.row).unwrap_or_default();
2325        let chars: Vec<char> = line.chars().collect();
2326        let run_start = cursor.col - sts;
2327        if (run_start..cursor.col).all(|i| chars.get(i).copied() == Some(' ')) {
2328            ed.mutate_edit(Edit::DeleteRange {
2329                start: Position::new(cursor.row, run_start),
2330                end: cursor,
2331                kind: MotionKind::Char,
2332            });
2333            ed.push_buffer_cursor_to_textarea();
2334            return true;
2335        }
2336    }
2337    let result = if cursor.col > 0 {
2338        ed.mutate_edit(Edit::DeleteRange {
2339            start: Position::new(cursor.row, cursor.col - 1),
2340            end: cursor,
2341            kind: MotionKind::Char,
2342        });
2343        true
2344    } else if cursor.row > 0 {
2345        let prev_row = cursor.row - 1;
2346        let prev_chars = buf_line_chars(&ed.buffer, prev_row);
2347        ed.mutate_edit(Edit::JoinLines {
2348            row: prev_row,
2349            count: 1,
2350            with_space: false,
2351        });
2352        buf_set_cursor_rc(&mut ed.buffer, prev_row, prev_chars);
2353        true
2354    } else {
2355        false
2356    };
2357    ed.push_buffer_cursor_to_textarea();
2358    result
2359}
2360
2361/// Delete the character under the cursor (vim `Delete`). Joins with the
2362/// next line when at end-of-line. Returns `true` when something was deleted.
2363pub(crate) fn insert_delete_bridge<H: crate::types::Host>(
2364    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2365) -> bool {
2366    use hjkl_buffer::{Edit, MotionKind, Position};
2367    ed.sync_buffer_content_from_textarea();
2368    let cursor = buf_cursor_pos(&ed.buffer);
2369    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
2370    let result = if cursor.col < line_chars {
2371        ed.mutate_edit(Edit::DeleteRange {
2372            start: cursor,
2373            end: Position::new(cursor.row, cursor.col + 1),
2374            kind: MotionKind::Char,
2375        });
2376        buf_set_cursor_pos(&mut ed.buffer, cursor);
2377        true
2378    } else if cursor.row + 1 < buf_row_count(&ed.buffer) {
2379        ed.mutate_edit(Edit::JoinLines {
2380            row: cursor.row,
2381            count: 1,
2382            with_space: false,
2383        });
2384        buf_set_cursor_pos(&mut ed.buffer, cursor);
2385        true
2386    } else {
2387        false
2388    };
2389    ed.push_buffer_cursor_to_textarea();
2390    result
2391}
2392
2393/// Direction for insert-mode arrow movement.
2394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2395pub enum InsertDir {
2396    Left,
2397    Right,
2398    Up,
2399    Down,
2400}
2401
2402/// Move the cursor one step in `dir`, breaking the undo group per
2403/// `undo_break_on_motion`. Clears the autopair pending-closes stack (cursor
2404/// moved off the pair). Returns `false` (no mutation).
2405pub(crate) fn insert_arrow_bridge<H: crate::types::Host>(
2406    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2407    dir: InsertDir,
2408) -> bool {
2409    ed.sync_buffer_content_from_textarea();
2410    ed.vim.pending_closes.clear();
2411    match dir {
2412        InsertDir::Left => {
2413            crate::motions::move_left(&mut ed.buffer, 1);
2414        }
2415        InsertDir::Right => {
2416            crate::motions::move_right_to_end(&mut ed.buffer, 1);
2417        }
2418        InsertDir::Up => {
2419            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2420            crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
2421        }
2422        InsertDir::Down => {
2423            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2424            crate::motions::move_down(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
2425        }
2426    }
2427    break_undo_group_in_insert(ed);
2428    ed.push_buffer_cursor_to_textarea();
2429    false
2430}
2431
2432/// Move the cursor to the start of the current line, breaking the undo group.
2433/// Clears the autopair pending-closes stack. Returns `false` (no mutation).
2434pub(crate) fn insert_home_bridge<H: crate::types::Host>(
2435    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2436) -> bool {
2437    ed.sync_buffer_content_from_textarea();
2438    ed.vim.pending_closes.clear();
2439    crate::motions::move_line_start(&mut ed.buffer);
2440    break_undo_group_in_insert(ed);
2441    ed.push_buffer_cursor_to_textarea();
2442    false
2443}
2444
2445/// Move the cursor to the end of the current line, breaking the undo group.
2446/// Clears the autopair pending-closes stack. Returns `false` (no mutation).
2447pub(crate) fn insert_end_bridge<H: crate::types::Host>(
2448    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2449) -> bool {
2450    ed.sync_buffer_content_from_textarea();
2451    ed.vim.pending_closes.clear();
2452    crate::motions::move_line_end(&mut ed.buffer);
2453    break_undo_group_in_insert(ed);
2454    ed.push_buffer_cursor_to_textarea();
2455    false
2456}
2457
2458/// Scroll up one full viewport height, moving the cursor with it.
2459/// Breaks the undo group. Returns `false` (no mutation).
2460pub(crate) fn insert_pageup_bridge<H: crate::types::Host>(
2461    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2462    viewport_h: u16,
2463) -> bool {
2464    let rows = viewport_h.saturating_sub(2).max(1) as isize;
2465    scroll_cursor_rows(ed, -rows);
2466    false
2467}
2468
2469/// Scroll down one full viewport height, moving the cursor with it.
2470/// Breaks the undo group. Returns `false` (no mutation).
2471pub(crate) fn insert_pagedown_bridge<H: crate::types::Host>(
2472    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2473    viewport_h: u16,
2474) -> bool {
2475    let rows = viewport_h.saturating_sub(2).max(1) as isize;
2476    scroll_cursor_rows(ed, rows);
2477    false
2478}
2479
2480/// Delete from the cursor back to the start of the previous word (`Ctrl-W`).
2481/// At col 0, joins with the previous line (vim semantics). Returns `true`
2482/// when something was deleted.
2483pub(crate) fn insert_ctrl_w_bridge<H: crate::types::Host>(
2484    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2485) -> bool {
2486    use hjkl_buffer::{Edit, MotionKind};
2487    ed.sync_buffer_content_from_textarea();
2488    let cursor = buf_cursor_pos(&ed.buffer);
2489    if cursor.row == 0 && cursor.col == 0 {
2490        return true;
2491    }
2492    crate::motions::move_word_back(&mut ed.buffer, false, 1, &ed.settings.iskeyword);
2493    let word_start = buf_cursor_pos(&ed.buffer);
2494    if word_start == cursor {
2495        return true;
2496    }
2497    buf_set_cursor_pos(&mut ed.buffer, cursor);
2498    ed.mutate_edit(Edit::DeleteRange {
2499        start: word_start,
2500        end: cursor,
2501        kind: MotionKind::Char,
2502    });
2503    ed.push_buffer_cursor_to_textarea();
2504    true
2505}
2506
2507/// Delete from the cursor back to the start of the current line (`Ctrl-U`).
2508/// No-op when already at column 0. Returns `true` when something was deleted.
2509pub(crate) fn insert_ctrl_u_bridge<H: crate::types::Host>(
2510    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2511) -> bool {
2512    use hjkl_buffer::{Edit, MotionKind, Position};
2513    ed.sync_buffer_content_from_textarea();
2514    let cursor = buf_cursor_pos(&ed.buffer);
2515    if cursor.col > 0 {
2516        ed.mutate_edit(Edit::DeleteRange {
2517            start: Position::new(cursor.row, 0),
2518            end: cursor,
2519            kind: MotionKind::Char,
2520        });
2521        ed.push_buffer_cursor_to_textarea();
2522    }
2523    true
2524}
2525
2526/// Delete one character backwards (`Ctrl-H`) — alias for Backspace in insert
2527/// mode. Joins with the previous line when at col 0. Returns `true` when
2528/// something was deleted.
2529pub(crate) fn insert_ctrl_h_bridge<H: crate::types::Host>(
2530    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2531) -> bool {
2532    use hjkl_buffer::{Edit, MotionKind, Position};
2533    ed.sync_buffer_content_from_textarea();
2534    let cursor = buf_cursor_pos(&ed.buffer);
2535    if cursor.col > 0 {
2536        ed.mutate_edit(Edit::DeleteRange {
2537            start: Position::new(cursor.row, cursor.col - 1),
2538            end: cursor,
2539            kind: MotionKind::Char,
2540        });
2541    } else if cursor.row > 0 {
2542        let prev_row = cursor.row - 1;
2543        let prev_chars = buf_line_chars(&ed.buffer, prev_row);
2544        ed.mutate_edit(Edit::JoinLines {
2545            row: prev_row,
2546            count: 1,
2547            with_space: false,
2548        });
2549        buf_set_cursor_rc(&mut ed.buffer, prev_row, prev_chars);
2550    }
2551    ed.push_buffer_cursor_to_textarea();
2552    true
2553}
2554
2555/// Indent the current line by one `shiftwidth` and shift the cursor right by
2556/// the same amount (`Ctrl-T`). Returns `true`.
2557pub(crate) fn insert_ctrl_t_bridge<H: crate::types::Host>(
2558    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2559) -> bool {
2560    let (row, col) = ed.cursor();
2561    let sw = ed.settings().shiftwidth;
2562    indent_rows(ed, row, row, 1);
2563    ed.jump_cursor(row, col + sw);
2564    true
2565}
2566
2567/// Outdent the current line by up to one `shiftwidth` and shift the cursor
2568/// left by the amount stripped (`Ctrl-D`). Returns `true`.
2569pub(crate) fn insert_ctrl_d_bridge<H: crate::types::Host>(
2570    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2571) -> bool {
2572    let (row, col) = ed.cursor();
2573    let before_len = buf_line_bytes(&ed.buffer, row);
2574    outdent_rows(ed, row, row, 1);
2575    let after_len = buf_line_bytes(&ed.buffer, row);
2576    let stripped = before_len.saturating_sub(after_len);
2577    let new_col = col.saturating_sub(stripped);
2578    ed.jump_cursor(row, new_col);
2579    true
2580}
2581
2582/// Enter "one-shot normal" mode (`Ctrl-O`): suspend insert for the next
2583/// complete normal-mode command, then return to insert. Returns `false`
2584/// (no buffer mutation — only mode state changes).
2585pub(crate) fn insert_ctrl_o_bridge<H: crate::types::Host>(
2586    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2587) -> bool {
2588    ed.vim.one_shot_normal = true;
2589    ed.vim.mode = Mode::Normal;
2590    // Phase 6.3: keep current_mode in sync for callers that bypass step().
2591    ed.vim.current_mode = crate::VimMode::Normal;
2592    false
2593}
2594
2595/// Arm the register-paste selector (`Ctrl-R`): the next typed character
2596/// names the register whose text will be inserted inline. Returns `false`
2597/// (no buffer mutation yet — mutation happens when the register char arrives).
2598pub(crate) fn insert_ctrl_r_bridge<H: crate::types::Host>(
2599    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2600) -> bool {
2601    ed.vim.insert_pending_register = true;
2602    false
2603}
2604
2605/// Paste the contents of `reg` at the cursor (the body of `Ctrl-R {reg}`).
2606/// Unknown or empty registers are a no-op. Returns `true` when text was
2607/// inserted.
2608pub(crate) fn insert_paste_register_bridge<H: crate::types::Host>(
2609    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2610    reg: char,
2611) -> bool {
2612    insert_register_text(ed, reg);
2613    // insert_register_text already calls mark_content_dirty internally;
2614    // return true to signal that the session row window should be widened.
2615    true
2616}
2617
2618/// Exit insert mode to Normal: finish the insert session, step the cursor one
2619/// cell left (vim convention), record the `gi` target, and update the sticky
2620/// column. Clears the autopair pending-closes stack. Returns `true` (always
2621/// consumed — even if no buffer mutation, the mode change itself is a
2622/// meaningful step).
2623pub(crate) fn leave_insert_to_normal_bridge<H: crate::types::Host>(
2624    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2625) -> bool {
2626    ed.vim.pending_closes.clear();
2627
2628    // ── Abbreviation expansion on Esc ────────────────────────────────────────
2629    // Esc triggers expansion for all abbreviation types.
2630    if !ed.vim.abbrevs.is_empty() {
2631        check_and_apply_abbrev(ed, AbbrevTrigger::Esc);
2632    }
2633
2634    finish_insert_session(ed);
2635    // Paired-tag auto-rename (issue #182). Must run BEFORE the cursor moves
2636    // left (the move-left is vim's "leave-insert cursor adjustment"; the
2637    // sync needs the post-insert cursor position to detect the tag name).
2638    sync_paired_tag_on_exit(ed);
2639    ed.vim.mode = Mode::Normal;
2640    // Phase 6.3: keep current_mode in sync for callers that bypass step().
2641    ed.vim.current_mode = crate::VimMode::Normal;
2642    let col = ed.cursor().1;
2643    ed.vim.last_insert_pos = Some(ed.cursor());
2644    if col > 0 {
2645        crate::motions::move_left(&mut ed.buffer, 1);
2646        ed.push_buffer_cursor_to_textarea();
2647    }
2648    ed.sticky_col = Some(ed.cursor().1);
2649    true
2650}
2651
2652// ─── Phase 6.2: normal-mode primitive bridges ──────────────────────────────
2653
2654/// Scroll direction for `scroll_full_page`, `scroll_half_page`, and
2655/// `scroll_line` controller methods.
2656#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2657pub enum ScrollDir {
2658    /// Move forward / downward (toward end of buffer).
2659    Down,
2660    /// Move backward / upward (toward start of buffer).
2661    Up,
2662}
2663
2664// ── Insert-mode entry bridges ──────────────────────────────────────────────
2665
2666/// `i` — begin Insert at the cursor. `count` is stored in the session for
2667/// insert-exit replay. Returns `true`.
2668pub(crate) fn enter_insert_i_bridge<H: crate::types::Host>(
2669    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2670    count: usize,
2671) {
2672    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::I));
2673}
2674
2675/// `I` — move to first non-blank then begin Insert. `count` stored for replay.
2676pub(crate) fn enter_insert_shift_i_bridge<H: crate::types::Host>(
2677    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2678    count: usize,
2679) {
2680    move_first_non_whitespace(ed);
2681    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftI));
2682}
2683
2684/// `a` — advance past the cursor char then begin Insert. `count` for replay.
2685pub(crate) fn enter_insert_a_bridge<H: crate::types::Host>(
2686    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2687    count: usize,
2688) {
2689    crate::motions::move_right_to_end(&mut ed.buffer, 1);
2690    ed.push_buffer_cursor_to_textarea();
2691    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::A));
2692}
2693
2694/// `A` — move to end-of-line then begin Insert. `count` for replay.
2695pub(crate) fn enter_insert_shift_a_bridge<H: crate::types::Host>(
2696    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2697    count: usize,
2698) {
2699    crate::motions::move_line_end(&mut ed.buffer);
2700    crate::motions::move_right_to_end(&mut ed.buffer, 1);
2701    ed.push_buffer_cursor_to_textarea();
2702    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftA));
2703}
2704
2705/// `o` — open a new line below the cursor and begin Insert.
2706/// When `formatoptions` has `o` and the current line is a comment, the
2707/// continuation prefix is inserted automatically.
2708pub(crate) fn open_line_below_bridge<H: crate::types::Host>(
2709    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2710    count: usize,
2711) {
2712    use hjkl_buffer::{Edit, Position};
2713    ed.push_undo();
2714    begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: false });
2715    ed.sync_buffer_content_from_textarea();
2716    let row = buf_cursor_pos(&ed.buffer).row;
2717    let line_chars = buf_line_chars(&ed.buffer, row);
2718    let prev_line = buf_line(&ed.buffer, row).unwrap_or_default();
2719
2720    // formatoptions `o`: continue comment on open-below.
2721    let comment_cont = if ed.settings.formatoptions.contains('o') {
2722        continue_comment(&ed.buffer, &ed.settings, row)
2723    } else {
2724        None
2725    };
2726
2727    let suffix = if let Some(cont) = comment_cont {
2728        format!("\n{cont}")
2729    } else {
2730        let indent = compute_enter_indent(&ed.settings, &prev_line);
2731        format!("\n{indent}")
2732    };
2733    ed.mutate_edit(Edit::InsertStr {
2734        at: Position::new(row, line_chars),
2735        text: suffix,
2736    });
2737    ed.push_buffer_cursor_to_textarea();
2738}
2739
2740/// `O` — open a new line above the cursor and begin Insert.
2741/// When `formatoptions` has `o` and the current line is a comment, the
2742/// continuation prefix is inserted automatically on the new line above.
2743pub(crate) fn open_line_above_bridge<H: crate::types::Host>(
2744    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2745    count: usize,
2746) {
2747    use hjkl_buffer::{Edit, Position};
2748    ed.push_undo();
2749    begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: true });
2750    ed.sync_buffer_content_from_textarea();
2751    let row = buf_cursor_pos(&ed.buffer).row;
2752
2753    // formatoptions `o`: continue comment on open-above (current line drives).
2754    let comment_cont = if ed.settings.formatoptions.contains('o') {
2755        continue_comment(&ed.buffer, &ed.settings, row)
2756    } else {
2757        None
2758    };
2759
2760    // `new_line_content` is the text of the new line (without the trailing `\n`).
2761    // Used to position the cursor at the end of that content after the move.
2762    let (insert_text, new_line_content) = if let Some(cont) = comment_cont {
2763        let content = cont.clone();
2764        (format!("{cont}\n"), content)
2765    } else {
2766        // vim `O` autoindent copies the CURRENT line's indent (the line the
2767        // cursor sits on, which becomes the line *below* the new one), NOT the
2768        // line above. Using the line above wrongly inherits a deeper child's
2769        // indent when the cursor is on a shallower line (e.g. explorer tree:
2770        // `O` on a dir whose preceding row is its own nested child).
2771        let cur = buf_line(&ed.buffer, row).unwrap_or_default();
2772        let indent = compute_enter_indent(&ed.settings, &cur);
2773        let content = indent.clone();
2774        (format!("{indent}\n"), content)
2775    };
2776    ed.mutate_edit(Edit::InsertStr {
2777        at: Position::new(row, 0),
2778        text: insert_text,
2779    });
2780    let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2781    crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
2782    let new_row = buf_cursor_pos(&ed.buffer).row;
2783    buf_set_cursor_rc(&mut ed.buffer, new_row, new_line_content.chars().count());
2784    ed.push_buffer_cursor_to_textarea();
2785}
2786
2787/// `R` — enter Replace mode (overstrike). `count` stored for replay.
2788pub(crate) fn enter_replace_mode_bridge<H: crate::types::Host>(
2789    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2790    count: usize,
2791) {
2792    // Guard delegated to begin_insert which already checks modifiable/Blame.
2793    begin_insert(ed, count.max(1), InsertReason::Replace);
2794}
2795
2796// ── Char / line ops ────────────────────────────────────────────────────────
2797
2798/// `x` — delete `count` chars forward from the cursor, writing to the unnamed
2799/// register. Records `LastChange::CharDel` for dot-repeat.
2800pub(crate) fn delete_char_forward_bridge<H: crate::types::Host>(
2801    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2802    count: usize,
2803) {
2804    do_char_delete(ed, true, count.max(1));
2805    if !ed.vim.replaying {
2806        ed.vim.last_change = Some(LastChange::CharDel {
2807            forward: true,
2808            count: count.max(1),
2809        });
2810    }
2811}
2812
2813/// `X` — delete `count` chars backward from the cursor, writing to the unnamed
2814/// register. Records `LastChange::CharDel` for dot-repeat.
2815pub(crate) fn delete_char_backward_bridge<H: crate::types::Host>(
2816    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2817    count: usize,
2818) {
2819    do_char_delete(ed, false, count.max(1));
2820    if !ed.vim.replaying {
2821        ed.vim.last_change = Some(LastChange::CharDel {
2822            forward: false,
2823            count: count.max(1),
2824        });
2825    }
2826}
2827
2828/// `s` — substitute `count` chars (delete then enter Insert). Equivalent to
2829/// `cl`. Records `LastChange::OpMotion` for dot-repeat.
2830pub(crate) fn substitute_char_bridge<H: crate::types::Host>(
2831    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2832    count: usize,
2833) {
2834    use hjkl_buffer::{Edit, MotionKind, Position};
2835    ed.push_undo();
2836    ed.sync_buffer_content_from_textarea();
2837    for _ in 0..count.max(1) {
2838        let cursor = buf_cursor_pos(&ed.buffer);
2839        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
2840        if cursor.col >= line_chars {
2841            break;
2842        }
2843        ed.mutate_edit(Edit::DeleteRange {
2844            start: cursor,
2845            end: Position::new(cursor.row, cursor.col + 1),
2846            kind: MotionKind::Char,
2847        });
2848    }
2849    ed.push_buffer_cursor_to_textarea();
2850    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
2851    if !ed.vim.replaying {
2852        ed.vim.last_change = Some(LastChange::OpMotion {
2853            op: Operator::Change,
2854            motion: Motion::Right,
2855            count: count.max(1),
2856            inserted: None,
2857        });
2858    }
2859}
2860
2861/// `S` — substitute the whole line (delete line contents then enter Insert).
2862/// Equivalent to `cc`. Records `LastChange::LineOp` for dot-repeat.
2863pub(crate) fn substitute_line_bridge<H: crate::types::Host>(
2864    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2865    count: usize,
2866) {
2867    execute_line_op(ed, Operator::Change, count.max(1));
2868    if !ed.vim.replaying {
2869        ed.vim.last_change = Some(LastChange::LineOp {
2870            op: Operator::Change,
2871            count: count.max(1),
2872            inserted: None,
2873        });
2874    }
2875}
2876
2877/// `D` — delete from the cursor to end-of-line, writing to the unnamed
2878/// register. Cursor parks on the new last char. Records for dot-repeat.
2879pub(crate) fn delete_to_eol_bridge<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2880    ed.push_undo();
2881    delete_to_eol(ed);
2882    crate::motions::move_left(&mut ed.buffer, 1);
2883    ed.push_buffer_cursor_to_textarea();
2884    if !ed.vim.replaying {
2885        ed.vim.last_change = Some(LastChange::DeleteToEol { inserted: None });
2886    }
2887}
2888
2889/// `C` — change from the cursor to end-of-line (delete then enter Insert).
2890/// Equivalent to `c$`. Shares the delete path with `D`.
2891pub(crate) fn change_to_eol_bridge<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2892    ed.push_undo();
2893    delete_to_eol(ed);
2894    begin_insert_noundo(ed, 1, InsertReason::DeleteToEol);
2895}
2896
2897/// `Y` — yank from the cursor to end-of-line (same as `y$` in Vim 8 default).
2898pub(crate) fn yank_to_eol_bridge<H: crate::types::Host>(
2899    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2900    count: usize,
2901) {
2902    apply_op_with_motion(ed, Operator::Yank, &Motion::LineEnd, count.max(1));
2903}
2904
2905/// `J` — join `count` lines (default 2) onto the current one, inserting a
2906/// single space between each pair (vim semantics). Records for dot-repeat.
2907pub(crate) fn join_line_bridge<H: crate::types::Host>(
2908    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2909    count: usize,
2910) {
2911    // vim `[count]J` joins `count` lines together — i.e. `count - 1` joins.
2912    // Bare `J` (and `1J`) join the current line with the one below (1 join).
2913    let joins = count.max(2) - 1;
2914    for _ in 0..joins {
2915        ed.push_undo();
2916        join_line(ed);
2917    }
2918    if !ed.vim.replaying {
2919        ed.vim.last_change = Some(LastChange::JoinLine { count: joins });
2920    }
2921}
2922
2923/// `~` — toggle the case of `count` chars from the cursor, advancing right.
2924/// Records `LastChange::ToggleCase` for dot-repeat.
2925pub(crate) fn toggle_case_at_cursor_bridge<H: crate::types::Host>(
2926    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2927    count: usize,
2928) {
2929    for _ in 0..count.max(1) {
2930        ed.push_undo();
2931        toggle_case_at_cursor(ed);
2932    }
2933    if !ed.vim.replaying {
2934        ed.vim.last_change = Some(LastChange::ToggleCase {
2935            count: count.max(1),
2936        });
2937    }
2938}
2939
2940/// `p` — paste the unnamed register (or `"reg` register) after the cursor.
2941/// Linewise yanks open a new line below; charwise pastes inline.
2942/// Records `LastChange::Paste` for dot-repeat.
2943pub(crate) fn paste_after_bridge<H: crate::types::Host>(
2944    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2945    count: usize,
2946) {
2947    paste_bridge(ed, false, count, false, false);
2948}
2949
2950/// `P` — paste the unnamed register (or `"reg` register) before the cursor.
2951/// Linewise yanks open a new line above; charwise pastes inline.
2952/// Records `LastChange::Paste` for dot-repeat.
2953pub(crate) fn paste_before_bridge<H: crate::types::Host>(
2954    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2955    count: usize,
2956) {
2957    paste_bridge(ed, true, count, false, false);
2958}
2959
2960/// Shared paste entry for `p`/`P`, `gp`/`gP` (`cursor_after`), and
2961/// `]p`/`[p` (`reindent`). Records `LastChange::Paste` for dot-repeat.
2962pub(crate) fn paste_bridge<H: crate::types::Host>(
2963    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2964    before: bool,
2965    count: usize,
2966    cursor_after: bool,
2967    reindent: bool,
2968) {
2969    do_paste(ed, before, count.max(1), cursor_after, reindent);
2970    if !ed.vim.replaying {
2971        ed.vim.last_change = Some(LastChange::Paste {
2972            before,
2973            count: count.max(1),
2974            cursor_after,
2975            reindent,
2976        });
2977    }
2978}
2979
2980// ── Jump bridges ───────────────────────────────────────────────────────────
2981
2982/// `<C-o>` — jump back `count` entries in the jumplist, saving the current
2983/// position on the forward stack so `<C-i>` can return.
2984pub(crate) fn jump_back_bridge<H: crate::types::Host>(
2985    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2986    count: usize,
2987) {
2988    for _ in 0..count.max(1) {
2989        jump_back(ed);
2990    }
2991}
2992
2993/// `<C-i>` / `Tab` — redo `count` jumps on the forward stack, saving the
2994/// current position on the backward stack.
2995pub(crate) fn jump_forward_bridge<H: crate::types::Host>(
2996    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2997    count: usize,
2998) {
2999    for _ in 0..count.max(1) {
3000        jump_forward(ed);
3001    }
3002}
3003
3004// ── Scroll bridges ─────────────────────────────────────────────────────────
3005
3006/// `<C-f>` / `<C-b>` — scroll the cursor by one full viewport height
3007/// (`h - 2` rows to preserve two-line overlap). `count` multiplies.
3008pub(crate) fn scroll_full_page_bridge<H: crate::types::Host>(
3009    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3010    dir: ScrollDir,
3011    count: usize,
3012) {
3013    ed.vim.scroll_anim_hint = true;
3014    let rows = viewport_full_rows(ed, count) as isize;
3015    match dir {
3016        ScrollDir::Down => scroll_cursor_rows(ed, rows),
3017        ScrollDir::Up => scroll_cursor_rows(ed, -rows),
3018    }
3019}
3020
3021/// `<C-d>` / `<C-u>` — scroll the cursor by half the viewport height.
3022/// `count` multiplies.
3023pub(crate) fn scroll_half_page_bridge<H: crate::types::Host>(
3024    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3025    dir: ScrollDir,
3026    count: usize,
3027) {
3028    ed.vim.scroll_anim_hint = true;
3029    let rows = viewport_half_rows(ed, count) as isize;
3030    match dir {
3031        ScrollDir::Down => scroll_cursor_rows(ed, rows),
3032        ScrollDir::Up => scroll_cursor_rows(ed, -rows),
3033    }
3034}
3035
3036/// `<C-e>` / `<C-y>` — scroll the viewport `count` lines without moving the
3037/// cursor (cursor is clamped to the new visible region if it would go
3038/// off-screen). `<C-e>` scrolls down; `<C-y>` scrolls up.
3039pub(crate) fn scroll_line_bridge<H: crate::types::Host>(
3040    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3041    dir: ScrollDir,
3042    count: usize,
3043) {
3044    let n = count.max(1);
3045    let total = buf_row_count(&ed.buffer);
3046    let last = total.saturating_sub(1);
3047    let h = ed.viewport_height_value() as usize;
3048    let vp = ed.host().viewport();
3049    let cur_top = vp.top_row;
3050    let new_top = match dir {
3051        ScrollDir::Down => (cur_top + n).min(last),
3052        ScrollDir::Up => cur_top.saturating_sub(n),
3053    };
3054    ed.set_viewport_top(new_top);
3055    // Clamp cursor to stay within the new visible region.
3056    let (row, col) = ed.cursor();
3057    let bot = (new_top + h).saturating_sub(1).min(last);
3058    let clamped = row.max(new_top).min(bot);
3059    if clamped != row {
3060        buf_set_cursor_rc(&mut ed.buffer, clamped, col);
3061        ed.push_buffer_cursor_to_textarea();
3062    }
3063}
3064
3065// ── Search bridges ─────────────────────────────────────────────────────────
3066
3067/// `n` / `N` — repeat the last search `count` times. `forward = true` means
3068/// repeat in the original search direction; `false` inverts it (like `N`).
3069pub(crate) fn search_repeat_bridge<H: crate::types::Host>(
3070    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3071    forward: bool,
3072    count: usize,
3073) {
3074    if let Some(pattern) = ed.vim.last_search.clone() {
3075        ed.push_search_pattern(&pattern);
3076    }
3077    if ed.search_state().pattern.is_none() {
3078        return;
3079    }
3080    let go_forward = ed.vim.last_search_forward == forward;
3081    for _ in 0..count.max(1) {
3082        if go_forward {
3083            ed.search_advance_forward(true);
3084        } else {
3085            ed.search_advance_backward(true);
3086        }
3087    }
3088    ed.push_buffer_cursor_to_textarea();
3089}
3090
3091/// `*` / `#` / `g*` / `g#` — search for the word under the cursor.
3092/// `forward` picks search direction; `whole_word` wraps in `\b...\b`.
3093/// `count` repeats the advance.
3094pub(crate) fn word_search_bridge<H: crate::types::Host>(
3095    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3096    forward: bool,
3097    whole_word: bool,
3098    count: usize,
3099) {
3100    word_at_cursor_search(ed, forward, whole_word, count.max(1));
3101}
3102
3103// ── Undo / redo confirmation wrappers (already public on Editor) ───────────
3104
3105/// `u` bridge — identical to `do_undo`; retained for Phase 6.6b audit.
3106/// The FSM now calls `ed.undo()` directly (Phase 6.6a).
3107#[allow(dead_code)]
3108#[inline]
3109pub(crate) fn do_undo_bridge<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
3110    do_undo(ed);
3111}
3112
3113// ─── Phase 6.3: visual-mode primitive bridges ──────────────────────────────
3114//
3115// Each `pub(crate)` free function is the extractable body of one visual-mode
3116// transition. These bridges set `vim.mode` directly AND write `current_mode`
3117// so that `Editor::vim_mode()` can read from the stable field without going
3118// through `public_mode()`.
3119//
3120// Pattern identical to Phase 6.1 / 6.2:
3121//   - Bridge fn is `pub(crate) fn *_bridge<H: Host>(ed, …)` in this file.
3122//   - Public wrapper is `pub fn *(&mut self, …)` in `editor.rs` with rustdoc.
3123
3124/// Drop the `Blame` view overlay whenever the input mode is no longer
3125/// `Normal`. BLAME is a Normal-only read-only view; entering Insert/Visual/etc.
3126/// (by keyboard, mouse drag, or programmatic transition) implicitly leaves it.
3127/// Called from every mode-transition funnel so the FSM is the single source of
3128/// truth — the host never has to police this.
3129#[inline]
3130pub(crate) fn drop_blame_if_left_normal<H: crate::types::Host>(
3131    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3132) {
3133    if ed.vim.current_mode != crate::VimMode::Normal {
3134        ed.vim.view = crate::ViewMode::Normal;
3135    }
3136}
3137
3138/// Helper — set both the FSM-internal `mode` and the stable `current_mode`
3139/// field in one call. Every Phase 6.3 bridge that changes mode calls this so
3140/// `vim_mode()` stays correct without going through the FSM's `step()` loop.
3141#[inline]
3142pub(crate) fn set_vim_mode_bridge<H: crate::types::Host>(
3143    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3144    mode: Mode,
3145) {
3146    ed.vim.mode = mode;
3147    ed.vim.current_mode = ed.vim.public_mode();
3148    drop_blame_if_left_normal(ed);
3149}
3150
3151/// `v` from Normal — enter charwise Visual mode. Anchors at the current
3152/// cursor position; the cursor IS the live end of the selection.
3153pub(crate) fn enter_visual_char_bridge<H: crate::types::Host>(
3154    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3155) {
3156    let cur = ed.cursor();
3157    ed.vim.visual_anchor = cur;
3158    set_vim_mode_bridge(ed, Mode::Visual);
3159}
3160
3161/// `V` from Normal — enter linewise Visual mode. Anchors the whole line
3162/// containing the current cursor; `o` still swaps the anchor row.
3163pub(crate) fn enter_visual_line_bridge<H: crate::types::Host>(
3164    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3165) {
3166    let (row, _) = ed.cursor();
3167    ed.vim.visual_line_anchor = row;
3168    set_vim_mode_bridge(ed, Mode::VisualLine);
3169}
3170
3171/// `<C-v>` from Normal — enter Visual-block mode. Anchors at the current
3172/// cursor; `block_vcol` is seeded from the cursor column so h/l navigation
3173/// preserves the desired virtual column.
3174pub(crate) fn enter_visual_block_bridge<H: crate::types::Host>(
3175    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3176) {
3177    let cur = ed.cursor();
3178    ed.vim.block_anchor = cur;
3179    ed.vim.block_vcol = cur.1;
3180    set_vim_mode_bridge(ed, Mode::VisualBlock);
3181}
3182
3183/// Esc from any visual mode — set `<` / `>` marks (per `:h v_:`), stash the
3184/// selection for `gv` re-entry, and return to Normal. Replicates the
3185/// `pre_visual_snapshot` logic in `step()` so callers outside the FSM get
3186/// identical behaviour.
3187pub(crate) fn exit_visual_to_normal_bridge<H: crate::types::Host>(
3188    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3189) {
3190    // Build the same snapshot that `step()` captures at pre-step time.
3191    let snap: Option<LastVisual> = match ed.vim.mode {
3192        Mode::Visual => Some(LastVisual {
3193            mode: Mode::Visual,
3194            anchor: ed.vim.visual_anchor,
3195            cursor: ed.cursor(),
3196            block_vcol: 0,
3197        }),
3198        Mode::VisualLine => Some(LastVisual {
3199            mode: Mode::VisualLine,
3200            anchor: (ed.vim.visual_line_anchor, 0),
3201            cursor: ed.cursor(),
3202            block_vcol: 0,
3203        }),
3204        Mode::VisualBlock => Some(LastVisual {
3205            mode: Mode::VisualBlock,
3206            anchor: ed.vim.block_anchor,
3207            cursor: ed.cursor(),
3208            block_vcol: ed.vim.block_vcol,
3209        }),
3210        _ => None,
3211    };
3212    // Transition to Normal first (matches FSM order).
3213    ed.vim.pending = Pending::None;
3214    ed.vim.count = 0;
3215    ed.vim.insert_session = None;
3216    set_vim_mode_bridge(ed, Mode::Normal);
3217    // Set `<` / `>` marks and stash `last_visual` — mirrors the post-step
3218    // logic in `step()` that fires when a visual → non-visual transition
3219    // is detected.
3220    if let Some(snap) = snap {
3221        let (lo, hi) = match snap.mode {
3222            Mode::Visual => {
3223                if snap.anchor <= snap.cursor {
3224                    (snap.anchor, snap.cursor)
3225                } else {
3226                    (snap.cursor, snap.anchor)
3227                }
3228            }
3229            Mode::VisualLine => {
3230                let r_lo = snap.anchor.0.min(snap.cursor.0);
3231                let r_hi = snap.anchor.0.max(snap.cursor.0);
3232                let vl_rope = ed.buffer().rope();
3233                let r_hi_clamped = r_hi.min(vl_rope.len_lines().saturating_sub(1));
3234                let last_col = hjkl_buffer::rope_line_str(&vl_rope, r_hi_clamped)
3235                    .chars()
3236                    .count()
3237                    .saturating_sub(1);
3238                ((r_lo, 0), (r_hi, last_col))
3239            }
3240            Mode::VisualBlock => {
3241                let (r1, c1) = snap.anchor;
3242                let (r2, c2) = snap.cursor;
3243                ((r1.min(r2), c1.min(c2)), (r1.max(r2), c1.max(c2)))
3244            }
3245            _ => {
3246                if snap.anchor <= snap.cursor {
3247                    (snap.anchor, snap.cursor)
3248                } else {
3249                    (snap.cursor, snap.anchor)
3250                }
3251            }
3252        };
3253        ed.set_mark('<', lo);
3254        ed.set_mark('>', hi);
3255        ed.vim.last_visual = Some(snap);
3256    }
3257}
3258
3259/// `o` in Visual / VisualLine / VisualBlock — swap the cursor and anchor
3260/// without mutating the selection range. In charwise mode the cursor jumps
3261/// to the old anchor and the anchor takes the old cursor. In linewise mode
3262/// the anchor *row* swaps with the current cursor row. In block mode the
3263/// block corners swap.
3264pub(crate) fn visual_o_toggle_bridge<H: crate::types::Host>(
3265    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3266) {
3267    match ed.vim.mode {
3268        Mode::Visual => {
3269            let cur = ed.cursor();
3270            let anchor = ed.vim.visual_anchor;
3271            ed.vim.visual_anchor = cur;
3272            ed.jump_cursor(anchor.0, anchor.1);
3273        }
3274        Mode::VisualLine => {
3275            let cur_row = ed.cursor().0;
3276            let anchor_row = ed.vim.visual_line_anchor;
3277            ed.vim.visual_line_anchor = cur_row;
3278            ed.jump_cursor(anchor_row, 0);
3279        }
3280        Mode::VisualBlock => {
3281            let cur = ed.cursor();
3282            let anchor = ed.vim.block_anchor;
3283            ed.vim.block_anchor = cur;
3284            ed.vim.block_vcol = anchor.1;
3285            ed.jump_cursor(anchor.0, anchor.1);
3286        }
3287        _ => {}
3288    }
3289}
3290
3291/// `gv` — restore the last visual selection (mode + anchor + cursor).
3292/// No-op if no selection was ever stored. Mirrors the `gv` arm in
3293/// `handle_normal_g`.
3294pub(crate) fn reenter_last_visual_bridge<H: crate::types::Host>(
3295    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3296) {
3297    if let Some(snap) = ed.vim.last_visual {
3298        match snap.mode {
3299            Mode::Visual => {
3300                ed.vim.visual_anchor = snap.anchor;
3301                set_vim_mode_bridge(ed, Mode::Visual);
3302            }
3303            Mode::VisualLine => {
3304                ed.vim.visual_line_anchor = snap.anchor.0;
3305                set_vim_mode_bridge(ed, Mode::VisualLine);
3306            }
3307            Mode::VisualBlock => {
3308                ed.vim.block_anchor = snap.anchor;
3309                ed.vim.block_vcol = snap.block_vcol;
3310                set_vim_mode_bridge(ed, Mode::VisualBlock);
3311            }
3312            _ => {}
3313        }
3314        ed.jump_cursor(snap.cursor.0, snap.cursor.1);
3315    }
3316}
3317
3318/// Direct mode-transition entry point for external controllers (e.g.
3319/// hjkl-vim). Sets both the FSM-internal `mode` and the stable
3320/// `current_mode`. Use sparingly — prefer the semantic primitives
3321/// (`enter_visual_char_bridge`, `enter_insert_i_bridge`, …) which also
3322/// set up the required bookkeeping (anchors, sessions, …).
3323pub(crate) fn set_mode_bridge<H: crate::types::Host>(
3324    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3325    mode: crate::VimMode,
3326) {
3327    let internal = match mode {
3328        crate::VimMode::Normal => Mode::Normal,
3329        crate::VimMode::Insert => Mode::Insert,
3330        crate::VimMode::Visual => Mode::Visual,
3331        crate::VimMode::VisualLine => Mode::VisualLine,
3332        crate::VimMode::VisualBlock => Mode::VisualBlock,
3333    };
3334    ed.vim.mode = internal;
3335    ed.vim.current_mode = mode;
3336    drop_blame_if_left_normal(ed);
3337}
3338
3339// ─── Normal / Visual / Operator-pending dispatcher removed in Phase 6.6g.3 ──
3340//
3341// `step_normal` and all private dispatch helpers (handle_after_op,
3342// handle_after_g, handle_after_z, handle_normal_only, etc.) were deleted.
3343// The canonical FSM body lives in `hjkl-vim::normal`. Use
3344// `hjkl_vim::dispatch_input` as the entry point.
3345//
3346// DELETED FUNCTION SIGNATURE (for archaeology):
3347// pub(crate) fn step_normal<H: crate::types::Host>(ed: ..., input: Input) -> bool {
3348
3349/// `m{ch}` — public controller entry point. Validates `ch` (must be
3350/// alphanumeric to match vim's mark-name rules) and records the current
3351/// cursor position under that name. Promoted to the public surface in 0.6.7
3352/// so the hjkl-vim `PendingState::SetMark` reducer can dispatch
3353/// `EngineCmd::SetMark` without re-entering the engine FSM.
3354/// `handle_set_mark` delegates here to avoid logic duplication.
3355pub(crate) fn set_mark_at_cursor<H: crate::types::Host>(
3356    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3357    ch: char,
3358) {
3359    if ch.is_ascii_lowercase() {
3360        let pos = ed.cursor();
3361        ed.set_mark(ch, pos);
3362    } else if ch.is_ascii_uppercase() {
3363        let pos = ed.cursor();
3364        let bid = ed.current_buffer_id();
3365        ed.set_global_mark(ch, bid, pos);
3366        tracing::debug!(
3367            mark = ch as u32,
3368            buffer_id = bid,
3369            row = pos.0,
3370            col = pos.1,
3371            "global mark set"
3372        );
3373    }
3374    // Invalid chars silently no-op (mirrors handle_set_mark behaviour).
3375}
3376
3377/// `'<ch>` / `` `<ch> `` — public controller entry point for lowercase and
3378/// special marks. Validates `ch` against the set of legal mark names
3379/// (lowercase, special: `'`/`` ` ``/`.`/`[`/`]`/`<`/`>`), resolves the
3380/// target position, and jumps the cursor. `linewise = true` → row only, col
3381/// snaps to first non-blank; `linewise = false` → exact (row, col).
3382///
3383/// Uppercase marks are handled by [`try_goto_mark`] which can return a
3384/// `MarkJump::CrossBuffer` for cross-buffer jumps.
3385pub(crate) fn goto_mark<H: crate::types::Host>(
3386    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3387    ch: char,
3388    linewise: bool,
3389) {
3390    let target = match ch {
3391        'a'..='z' => ed.mark(ch),
3392        '\'' | '`' => ed.vim.jump_back.last().copied(),
3393        '.' => ed.vim.last_edit_pos,
3394        '[' | ']' | '<' | '>' => ed.mark(ch),
3395        _ => None,
3396    };
3397    let Some((row, col)) = target else {
3398        return;
3399    };
3400    let pre = ed.cursor();
3401    let (r, c_clamped) = clamp_pos(ed, (row, col));
3402    if linewise {
3403        buf_set_cursor_rc(&mut ed.buffer, r, 0);
3404        ed.push_buffer_cursor_to_textarea();
3405        move_first_non_whitespace(ed);
3406    } else {
3407        buf_set_cursor_rc(&mut ed.buffer, r, c_clamped);
3408        ed.push_buffer_cursor_to_textarea();
3409    }
3410    if ed.cursor() != pre {
3411        ed.push_jump(pre);
3412    }
3413    ed.sticky_col = Some(ed.cursor().1);
3414}
3415
3416/// Unified mark-jump entry point that returns a [`crate::editor::MarkJump`]
3417/// so the app layer can decide whether to switch buffers.
3418///
3419/// - Uppercase marks (`'A'`–`'Z'`) look in `global_marks`. If the stored
3420///   `buffer_id` differs from `ed.current_buffer_id()`, returns
3421///   `CrossBuffer`. Same-buffer uppercase marks execute the jump normally.
3422/// - All other legal mark chars delegate to [`goto_mark`] and return
3423///   `SameBuffer`.
3424pub(crate) fn try_goto_mark<H: crate::types::Host>(
3425    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3426    ch: char,
3427    linewise: bool,
3428) -> crate::editor::MarkJump {
3429    use crate::editor::MarkJump;
3430    match ch {
3431        'A'..='Z' => {
3432            let Some((bid, row, col)) = ed.global_mark(ch) else {
3433                return MarkJump::Unset;
3434            };
3435            if bid != ed.current_buffer_id() {
3436                tracing::debug!(
3437                    mark = ch as u32,
3438                    buffer_id = bid,
3439                    row,
3440                    col,
3441                    "global mark cross-buffer jump"
3442                );
3443                return MarkJump::CrossBuffer {
3444                    buffer_id: bid,
3445                    row,
3446                    col,
3447                };
3448            }
3449            // Same buffer — execute the jump normally.
3450            let pre = ed.cursor();
3451            let (r, c_clamped) = clamp_pos(ed, (row, col));
3452            if linewise {
3453                buf_set_cursor_rc(&mut ed.buffer, r, 0);
3454                ed.push_buffer_cursor_to_textarea();
3455                move_first_non_whitespace(ed);
3456            } else {
3457                buf_set_cursor_rc(&mut ed.buffer, r, c_clamped);
3458                ed.push_buffer_cursor_to_textarea();
3459            }
3460            if ed.cursor() != pre {
3461                ed.push_jump(pre);
3462            }
3463            ed.sticky_col = Some(ed.cursor().1);
3464            MarkJump::SameBuffer
3465        }
3466        'a'..='z' | '\'' | '`' | '.' | '[' | ']' | '<' | '>' => {
3467            goto_mark(ed, ch, linewise);
3468            MarkJump::SameBuffer
3469        }
3470        _ => MarkJump::Unset,
3471    }
3472}
3473
3474/// `true` when `op` records a `last_change` entry for dot-repeat purposes.
3475/// Promoted to `pub` in Phase 6.6e so `hjkl-vim::normal` can use it without
3476/// duplicating the logic.
3477pub fn op_is_change(op: Operator) -> bool {
3478    matches!(op, Operator::Delete | Operator::Change)
3479}
3480
3481// ─── Jumplist (Ctrl-o / Ctrl-i) ────────────────────────────────────────────
3482
3483/// Max jumplist depth. Matches vim default.
3484pub(crate) const JUMPLIST_MAX: usize = 100;
3485
3486/// `Ctrl-o` — jump back to the most recent pre-jump position. Saves
3487/// the current cursor onto the forward stack so `Ctrl-i` can return.
3488fn jump_back<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
3489    let Some(target) = ed.vim.jump_back.pop() else {
3490        return;
3491    };
3492    let cur = ed.cursor();
3493    ed.vim.jump_fwd.push(cur);
3494    let (r, c) = clamp_pos(ed, target);
3495    ed.jump_cursor(r, c);
3496    ed.sticky_col = Some(c);
3497}
3498
3499/// `Ctrl-i` / `Tab` — redo the last `Ctrl-o`. Saves the current cursor
3500/// onto the back stack.
3501fn jump_forward<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
3502    let Some(target) = ed.vim.jump_fwd.pop() else {
3503        return;
3504    };
3505    let cur = ed.cursor();
3506    ed.vim.jump_back.push(cur);
3507    if ed.vim.jump_back.len() > JUMPLIST_MAX {
3508        ed.vim.jump_back.remove(0);
3509    }
3510    let (r, c) = clamp_pos(ed, target);
3511    ed.jump_cursor(r, c);
3512    ed.sticky_col = Some(c);
3513}
3514
3515/// Clamp a stored `(row, col)` to the live buffer in case edits
3516/// shrunk the document between push and pop.
3517fn clamp_pos<H: crate::types::Host>(
3518    ed: &Editor<hjkl_buffer::Buffer, H>,
3519    pos: (usize, usize),
3520) -> (usize, usize) {
3521    let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
3522    let r = pos.0.min(last_row);
3523    let line_len = buf_line_chars(&ed.buffer, r);
3524    let c = pos.1.min(line_len.saturating_sub(1));
3525    (r, c)
3526}
3527
3528/// True for motions that vim treats as jumps (pushed onto the jumplist).
3529fn is_big_jump(motion: &Motion) -> bool {
3530    matches!(
3531        motion,
3532        Motion::FileTop
3533            | Motion::FileBottom
3534            | Motion::MatchBracket
3535            | Motion::WordAtCursor { .. }
3536            | Motion::SearchNext { .. }
3537            | Motion::ViewportTop
3538            | Motion::ViewportMiddle
3539            | Motion::ViewportBottom
3540    )
3541}
3542
3543// ─── Scroll helpers (Ctrl-d / Ctrl-u / Ctrl-f / Ctrl-b) ────────────────────
3544
3545/// Half-viewport row count, with a floor of 1 so tiny / un-rendered
3546/// viewports still step by a single row. `count` multiplies.
3547fn viewport_half_rows<H: crate::types::Host>(
3548    ed: &Editor<hjkl_buffer::Buffer, H>,
3549    count: usize,
3550) -> usize {
3551    let h = ed.viewport_height_value() as usize;
3552    (h / 2).max(1).saturating_mul(count.max(1))
3553}
3554
3555/// Full-viewport row count. Vim conventionally keeps 2 lines of overlap
3556/// between successive `Ctrl-f` pages; we approximate with `h - 2`.
3557fn viewport_full_rows<H: crate::types::Host>(
3558    ed: &Editor<hjkl_buffer::Buffer, H>,
3559    count: usize,
3560) -> usize {
3561    let h = ed.viewport_height_value() as usize;
3562    h.saturating_sub(2).max(1).saturating_mul(count.max(1))
3563}
3564
3565/// Move the cursor by `delta` rows (positive = down, negative = up),
3566/// clamp to the document, then land at the first non-blank on the new
3567/// row. The textarea viewport auto-scrolls to keep the cursor visible
3568/// when the cursor pushes off-screen.
3569fn scroll_cursor_rows<H: crate::types::Host>(
3570    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3571    delta: isize,
3572) {
3573    if delta == 0 {
3574        return;
3575    }
3576    ed.sync_buffer_content_from_textarea();
3577    let (row, _) = ed.cursor();
3578    let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
3579    let target = (row as isize + delta).max(0).min(last_row as isize) as usize;
3580    buf_set_cursor_rc(&mut ed.buffer, target, 0);
3581    crate::motions::move_first_non_blank(&mut ed.buffer);
3582    ed.push_buffer_cursor_to_textarea();
3583    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
3584}
3585
3586// ─── Motion parsing ────────────────────────────────────────────────────────
3587
3588/// Parse the first key of a normal/visual-mode motion. Returns `None` for
3589/// keys that don't start a motion (operator keys, command keys, etc.).
3590/// Promoted to `pub` in Phase 6.6e so `hjkl-vim::normal` can call it.
3591pub fn parse_motion(input: &Input) -> Option<Motion> {
3592    if input.ctrl {
3593        // `<C-h>` is vim's `<BS>` — a wrapping left motion. (The hjkl app
3594        // rebinds `<C-h>` to window-focus-left before it reaches the engine;
3595        // this keeps it correct for engine consumers that don't override it.)
3596        if input.key == Key::Char('h') {
3597            return Some(Motion::BackspaceBack);
3598        }
3599        return None;
3600    }
3601    match input.key {
3602        Key::Char('h') | Key::Left => Some(Motion::Left),
3603        Key::Char('l') | Key::Right => Some(Motion::Right),
3604        // `<Space>`/`<BS>` are vim's right/left motions that WRAP at line ends
3605        // (default `whichwrap=b,s`), unlike `l`/`h`/arrows which never wrap.
3606        // Operators (`d<Space>`/`d<BS>`) act on one char mid-line like `dl`/`dh`.
3607        Key::Char(' ') => Some(Motion::SpaceFwd),
3608        Key::Backspace => Some(Motion::BackspaceBack),
3609        Key::Char('j') | Key::Down => Some(Motion::Down),
3610        // `+` / `<CR>` — first non-blank of next line (linewise, count-aware).
3611        Key::Char('+') | Key::Enter => Some(Motion::FirstNonBlankNextLine),
3612        // `-` — first non-blank of previous line (linewise, count-aware).
3613        Key::Char('-') => Some(Motion::FirstNonBlankPrevLine),
3614        // `_` — first non-blank of current line, or count-1 lines down (linewise).
3615        Key::Char('_') => Some(Motion::FirstNonBlankLine),
3616        Key::Char('k') | Key::Up => Some(Motion::Up),
3617        Key::Char('w') => Some(Motion::WordFwd),
3618        Key::Char('W') => Some(Motion::BigWordFwd),
3619        Key::Char('b') => Some(Motion::WordBack),
3620        Key::Char('B') => Some(Motion::BigWordBack),
3621        Key::Char('e') => Some(Motion::WordEnd),
3622        Key::Char('E') => Some(Motion::BigWordEnd),
3623        Key::Char('0') | Key::Home => Some(Motion::LineStart),
3624        Key::Char('^') => Some(Motion::FirstNonBlank),
3625        Key::Char('$') | Key::End => Some(Motion::LineEnd),
3626        Key::Char('G') => Some(Motion::FileBottom),
3627        Key::Char('%') => Some(Motion::MatchBracket),
3628        Key::Char(';') => Some(Motion::FindRepeat { reverse: false }),
3629        Key::Char(',') => Some(Motion::FindRepeat { reverse: true }),
3630        Key::Char('*') => Some(Motion::WordAtCursor {
3631            forward: true,
3632            whole_word: true,
3633        }),
3634        Key::Char('#') => Some(Motion::WordAtCursor {
3635            forward: false,
3636            whole_word: true,
3637        }),
3638        Key::Char('n') => Some(Motion::SearchNext { reverse: false }),
3639        Key::Char('N') => Some(Motion::SearchNext { reverse: true }),
3640        Key::Char('H') => Some(Motion::ViewportTop),
3641        Key::Char('M') => Some(Motion::ViewportMiddle),
3642        Key::Char('L') => Some(Motion::ViewportBottom),
3643        Key::Char('{') => Some(Motion::ParagraphPrev),
3644        Key::Char('}') => Some(Motion::ParagraphNext),
3645        Key::Char('(') => Some(Motion::SentencePrev),
3646        Key::Char(')') => Some(Motion::SentenceNext),
3647        Key::Char('|') => Some(Motion::GotoColumn),
3648        _ => None,
3649    }
3650}
3651
3652// ─── Motion execution ──────────────────────────────────────────────────────
3653
3654pub(crate) fn execute_motion<H: crate::types::Host>(
3655    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3656    motion: Motion,
3657    count: usize,
3658) {
3659    let count = count.max(1);
3660    // `;`/`,` smart fallback: if the last horizontal motion was a sneak
3661    // digraph, repeat via apply_sneak instead of find-char.
3662    if let Motion::FindRepeat { reverse } = motion
3663        && ed.vim.last_horizontal_motion == LastHorizontalMotion::Sneak
3664    {
3665        if let Some(((c1, c2), fwd)) = ed.vim.last_sneak {
3666            let effective_fwd = if reverse { !fwd } else { fwd };
3667            apply_sneak(ed, c1, c2, effective_fwd, count);
3668        }
3669        return;
3670    }
3671    // FindRepeat needs the stored direction.
3672    let motion = match motion {
3673        Motion::FindRepeat { reverse } => match ed.vim.last_find {
3674            Some((ch, forward, till)) => Motion::Find {
3675                ch,
3676                forward: if reverse { !forward } else { forward },
3677                till,
3678            },
3679            None => return,
3680        },
3681        other => other,
3682    };
3683    let pre_pos = ed.cursor();
3684    let pre_col = pre_pos.1;
3685    apply_motion_cursor(ed, &motion, count);
3686    let post_pos = ed.cursor();
3687    if is_big_jump(&motion) && pre_pos != post_pos {
3688        ed.push_jump(pre_pos);
3689    }
3690    apply_sticky_col(ed, &motion, pre_col);
3691    // Phase 7b: keep the migration buffer's cursor + viewport in
3692    // lockstep with the textarea after every motion. Once 7c lands
3693    // (motions ported onto the buffer's API), this flips: the
3694    // buffer becomes authoritative and the textarea mirrors it.
3695    ed.sync_buffer_from_textarea();
3696}
3697
3698// ─── Keymap-layer motion controller ────────────────────────────────────────
3699
3700/// Wrapper around `execute_motion` that also syncs `block_vcol` when in
3701/// VisualBlock mode. The engine FSM's `step()` already does this (line ~2001);
3702/// the keymap path (`apply_motion_kind`) must do the same so VisualBlock h/l
3703/// extend the highlighted region correctly.
3704///
3705/// `update_block_vcol` is only a no-op for vertical / non-horizontal motions
3706/// (Up, Down, FileTop, FileBottom, Search), so passing every motion through is
3707/// safe — the function's own match arm handles the no-op case.
3708fn execute_motion_with_block_vcol<H: crate::types::Host>(
3709    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3710    motion: Motion,
3711    count: usize,
3712) {
3713    let motion_copy = motion.clone();
3714    execute_motion(ed, motion, count);
3715    if ed.vim.mode == Mode::VisualBlock {
3716        update_block_vcol(ed, &motion_copy);
3717    }
3718}
3719
3720/// Execute a `crate::MotionKind` cursor motion. Called by the host's
3721/// `Editor::apply_motion` controller method — the keymap dispatch path for
3722/// Phase 3a of kryptic-sh/hjkl#69.
3723///
3724/// Maps each variant to the same internal primitives used by the engine FSM
3725/// so cursor, sticky column, scroll, and sync semantics are identical.
3726///
3727/// # Visual-mode post-motion sync audit (2026-05-13)
3728///
3729/// After `execute_motion`, two things are conditional on visual mode:
3730///
3731/// 1. **VisualBlock `block_vcol` sync** — `update_block_vcol(ed, &motion)` is
3732///    called when `mode == Mode::VisualBlock`.  This is replicated here via
3733///    `execute_motion_with_block_vcol` for every motion variant below.
3734///
3735/// 2. **`last_find` update** — `Motion::Find` is dispatched through
3736///    `Pending::Find → apply_find_char` (in hjkl-vim), which writes `last_find`
3737///    itself.  A post-motion `last_find` write here would be dead code.  The keymap
3738///    path writes `last_find` in `apply_find_char` (called from
3739///    `Editor::find_char`), so no gap exists here.
3740///
3741/// No VisualLine-specific or Visual-specific post-motion work exists in the
3742/// FSM: anchors (`visual_anchor`, `visual_line_anchor`, `block_anchor`) are
3743/// only written on mode-entry or `o`-swap, never on motion.  The `<`/`>`
3744/// mark update in `step()` fires only on visual→normal transition, not after
3745/// each motion.  There are **no further sync gaps** beyond the `block_vcol`
3746/// fix already applied above.
3747pub(crate) fn apply_motion_kind<H: crate::types::Host>(
3748    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3749    kind: crate::MotionKind,
3750    count: usize,
3751) {
3752    let count = count.max(1);
3753    match kind {
3754        crate::MotionKind::CharLeft => {
3755            execute_motion_with_block_vcol(ed, Motion::Left, count);
3756        }
3757        crate::MotionKind::CharRight => {
3758            execute_motion_with_block_vcol(ed, Motion::Right, count);
3759        }
3760        crate::MotionKind::LineDown => {
3761            execute_motion_with_block_vcol(ed, Motion::Down, count);
3762        }
3763        crate::MotionKind::LineUp => {
3764            execute_motion_with_block_vcol(ed, Motion::Up, count);
3765        }
3766        crate::MotionKind::FirstNonBlankDown => {
3767            // `+`: move down `count` lines then land on first non-blank.
3768            // Not a big-jump (no jump-list entry), sticky col set to the
3769            // landed column (first non-blank). Mirrors scroll_cursor_rows
3770            // semantics but goes through the fold-aware buffer motion path.
3771            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3772            crate::motions::move_down(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
3773            crate::motions::move_first_non_blank(&mut ed.buffer);
3774            ed.push_buffer_cursor_to_textarea();
3775            ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
3776            ed.sync_buffer_from_textarea();
3777        }
3778        crate::MotionKind::FirstNonBlankUp => {
3779            // `-`: move up `count` lines then land on first non-blank.
3780            // Same pattern as FirstNonBlankDown, direction reversed.
3781            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3782            crate::motions::move_up(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
3783            crate::motions::move_first_non_blank(&mut ed.buffer);
3784            ed.push_buffer_cursor_to_textarea();
3785            ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
3786            ed.sync_buffer_from_textarea();
3787        }
3788        crate::MotionKind::WordForward => {
3789            execute_motion_with_block_vcol(ed, Motion::WordFwd, count);
3790        }
3791        crate::MotionKind::BigWordForward => {
3792            execute_motion_with_block_vcol(ed, Motion::BigWordFwd, count);
3793        }
3794        crate::MotionKind::WordBackward => {
3795            execute_motion_with_block_vcol(ed, Motion::WordBack, count);
3796        }
3797        crate::MotionKind::BigWordBackward => {
3798            execute_motion_with_block_vcol(ed, Motion::BigWordBack, count);
3799        }
3800        crate::MotionKind::WordEnd => {
3801            execute_motion_with_block_vcol(ed, Motion::WordEnd, count);
3802        }
3803        crate::MotionKind::BigWordEnd => {
3804            execute_motion_with_block_vcol(ed, Motion::BigWordEnd, count);
3805        }
3806        crate::MotionKind::LineStart => {
3807            // `0` / `<Home>`: first column of the current line.
3808            // count is ignored — matches vim `0` semantics.
3809            execute_motion_with_block_vcol(ed, Motion::LineStart, 1);
3810        }
3811        crate::MotionKind::FirstNonBlank => {
3812            // `^`: first non-blank column on the current line.
3813            // count is ignored — matches vim `^` semantics.
3814            execute_motion_with_block_vcol(ed, Motion::FirstNonBlank, 1);
3815        }
3816        crate::MotionKind::GotoLine => {
3817            // `G`: bare `G` → last line; `count G` → jump to line `count`.
3818            // apply_motion_kind normalises the raw count to count.max(1)
3819            // above, so count == 1 means "bare G" (last line) and count > 1
3820            // means "go to line N". execute_motion's FileBottom arm applies
3821            // the same `count > 1` check before calling move_bottom, so the
3822            // convention aligns: pass count straight through.
3823            // FileBottom is vertical — update_block_vcol is a no-op here
3824            // (preserves vcol), so the helper is safe to use.
3825            execute_motion_with_block_vcol(ed, Motion::FileBottom, count);
3826        }
3827        crate::MotionKind::LineEnd => {
3828            // `$` / `<End>`: last character on the current line.
3829            // count is ignored at the keymap-path level (vim `N$` moves
3830            // down N-1 lines then lands at line-end; not yet wired).
3831            execute_motion_with_block_vcol(ed, Motion::LineEnd, 1);
3832        }
3833        crate::MotionKind::FindRepeat => {
3834            // `;` — repeat last f/F/t/T in the same direction.
3835            // execute_motion resolves FindRepeat via ed.vim.last_find;
3836            // no-op if no prior find exists (None arm returns early).
3837            execute_motion_with_block_vcol(ed, Motion::FindRepeat { reverse: false }, count);
3838        }
3839        crate::MotionKind::FindRepeatReverse => {
3840            // `,` — repeat last f/F/t/T in the reverse direction.
3841            // execute_motion resolves FindRepeat via ed.vim.last_find;
3842            // no-op if no prior find exists (None arm returns early).
3843            execute_motion_with_block_vcol(ed, Motion::FindRepeat { reverse: true }, count);
3844        }
3845        crate::MotionKind::BracketMatch => {
3846            // `%` — jump to the matching bracket.
3847            // count is passed through; engine-side matching_bracket handles
3848            // the no-match case as a no-op (cursor stays). Engine FSM arm
3849            // for `%` in parse_motion is kept intact for macro-replay.
3850            execute_motion_with_block_vcol(ed, Motion::MatchBracket, count);
3851        }
3852        crate::MotionKind::ViewportTop => {
3853            // `H` — cursor to top of visible viewport, then count-1 rows down.
3854            // Engine FSM arm for `H` in parse_motion is kept intact for macro-replay.
3855            execute_motion_with_block_vcol(ed, Motion::ViewportTop, count);
3856        }
3857        crate::MotionKind::ViewportMiddle => {
3858            // `M` — cursor to middle of visible viewport; count ignored.
3859            // Engine FSM arm for `M` in parse_motion is kept intact for macro-replay.
3860            execute_motion_with_block_vcol(ed, Motion::ViewportMiddle, count);
3861        }
3862        crate::MotionKind::ViewportBottom => {
3863            // `L` — cursor to bottom of visible viewport, then count-1 rows up.
3864            // Engine FSM arm for `L` in parse_motion is kept intact for macro-replay.
3865            execute_motion_with_block_vcol(ed, Motion::ViewportBottom, count);
3866        }
3867        crate::MotionKind::HalfPageDown => {
3868            // `<C-d>` — half page down, count multiplies the distance.
3869            // Calls scroll_cursor_rows directly rather than adding a Motion enum
3870            // variant, keeping engine Motion churn minimal.
3871            scroll_cursor_rows(ed, viewport_half_rows(ed, count) as isize);
3872        }
3873        crate::MotionKind::HalfPageUp => {
3874            // `<C-u>` — half page up, count multiplies the distance.
3875            // Direct call mirrors the FSM Ctrl-u arm. No new Motion variant.
3876            scroll_cursor_rows(ed, -(viewport_half_rows(ed, count) as isize));
3877        }
3878        crate::MotionKind::FullPageDown => {
3879            // `<C-f>` — full page down (2-line overlap), count multiplies.
3880            // Direct call mirrors the FSM Ctrl-f arm. No new Motion variant.
3881            scroll_cursor_rows(ed, viewport_full_rows(ed, count) as isize);
3882        }
3883        crate::MotionKind::FullPageUp => {
3884            // `<C-b>` — full page up (2-line overlap), count multiplies.
3885            // Direct call mirrors the FSM Ctrl-b arm. No new Motion variant.
3886            scroll_cursor_rows(ed, -(viewport_full_rows(ed, count) as isize));
3887        }
3888        crate::MotionKind::FirstNonBlankLine => {
3889            execute_motion_with_block_vcol(ed, Motion::FirstNonBlankLine, count);
3890        }
3891        crate::MotionKind::SectionBackward => {
3892            execute_motion_with_block_vcol(ed, Motion::SectionBackward, count);
3893        }
3894        crate::MotionKind::SectionForward => {
3895            execute_motion_with_block_vcol(ed, Motion::SectionForward, count);
3896        }
3897        crate::MotionKind::SectionEndBackward => {
3898            execute_motion_with_block_vcol(ed, Motion::SectionEndBackward, count);
3899        }
3900        crate::MotionKind::SectionEndForward => {
3901            execute_motion_with_block_vcol(ed, Motion::SectionEndForward, count);
3902        }
3903    }
3904}
3905
3906/// Restore the cursor to the sticky column after vertical motions and
3907/// sync the sticky column to the current column after horizontal ones.
3908/// `pre_col` is the cursor column captured *before* the motion — used
3909/// to bootstrap the sticky value on the very first motion.
3910fn apply_sticky_col<H: crate::types::Host>(
3911    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3912    motion: &Motion,
3913    pre_col: usize,
3914) {
3915    if is_vertical_motion(motion) {
3916        let want = ed.sticky_col.unwrap_or(pre_col);
3917        // Record the desired column so the next vertical motion sees
3918        // it even if we currently clamped to a shorter row.
3919        ed.sticky_col = Some(want);
3920        let (row, _) = ed.cursor();
3921        let line_len = buf_line_chars(&ed.buffer, row);
3922        // Clamp to the last char on non-empty lines (vim normal-mode
3923        // never parks the cursor one past end of line). Empty lines
3924        // collapse to col 0.
3925        let max_col = line_len.saturating_sub(1);
3926        let target = want.min(max_col);
3927        // raw primitive: this function MUST preserve the un-clamped `want`
3928        // already stored in `ed.sticky_col`; `jump_cursor` would overwrite
3929        // it with the clamped `target`.
3930        buf_set_cursor_rc(&mut ed.buffer, row, target);
3931    } else {
3932        // Horizontal motion or non-motion: sticky column tracks the
3933        // new cursor column so the *next* vertical motion aims there.
3934        ed.sticky_col = Some(ed.cursor().1);
3935    }
3936}
3937
3938fn is_vertical_motion(motion: &Motion) -> bool {
3939    // Only j / k preserve the sticky column. Everything else (search,
3940    // gg / G, word jumps, etc.) lands at the match's own column so the
3941    // sticky value should sync to the new cursor column.
3942    matches!(
3943        motion,
3944        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown
3945    )
3946}
3947
3948fn apply_motion_cursor<H: crate::types::Host>(
3949    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3950    motion: &Motion,
3951    count: usize,
3952) {
3953    apply_motion_cursor_ctx(ed, motion, count, false)
3954}
3955
3956pub(crate) fn apply_motion_cursor_ctx<H: crate::types::Host>(
3957    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3958    motion: &Motion,
3959    count: usize,
3960    as_operator: bool,
3961) {
3962    match motion {
3963        Motion::Left => {
3964            // `h` — Buffer clamps at col 0 (no wrap), matching vim.
3965            crate::motions::move_left(&mut ed.buffer, count);
3966            ed.push_buffer_cursor_to_textarea();
3967        }
3968        Motion::Right => {
3969            // `l` — operator-motion context (`dl`/`cl`/`yl`) is allowed
3970            // one past the last char so the range includes it; cursor
3971            // context clamps at the last char.
3972            if as_operator {
3973                crate::motions::move_right_to_end(&mut ed.buffer, count);
3974            } else {
3975                crate::motions::move_right_in_line(&mut ed.buffer, count);
3976            }
3977            ed.push_buffer_cursor_to_textarea();
3978        }
3979        Motion::SpaceFwd => {
3980            // `<Space>` — wraps to next line at EOL in cursor context; mid-line
3981            // char delete like `l` under an operator (`d<Space>`).
3982            if as_operator {
3983                crate::motions::move_right_to_end(&mut ed.buffer, count);
3984            } else {
3985                crate::motions::move_space_fwd(&mut ed.buffer, count);
3986            }
3987            ed.push_buffer_cursor_to_textarea();
3988        }
3989        Motion::BackspaceBack => {
3990            // `<BS>` — wraps to prev line's last char at BOL in cursor context;
3991            // mid-line char move like `h` under an operator (`d<BS>`).
3992            if as_operator {
3993                crate::motions::move_left(&mut ed.buffer, count);
3994            } else {
3995                crate::motions::move_backspace_back(&mut ed.buffer, count);
3996            }
3997            ed.push_buffer_cursor_to_textarea();
3998        }
3999        Motion::Up => {
4000            // Final col is set by `apply_sticky_col` below — push the
4001            // post-move row to the textarea and let sticky tracking
4002            // finish the work.
4003            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
4004            crate::motions::move_up(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
4005            ed.push_buffer_cursor_to_textarea();
4006        }
4007        Motion::Down => {
4008            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
4009            crate::motions::move_down(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
4010            ed.push_buffer_cursor_to_textarea();
4011        }
4012        Motion::ScreenUp => {
4013            let v = *ed.host.viewport();
4014            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
4015            crate::motions::move_screen_up(&mut ed.buffer, &folds, &v, count, &mut ed.sticky_col);
4016            ed.push_buffer_cursor_to_textarea();
4017        }
4018        Motion::ScreenDown => {
4019            let v = *ed.host.viewport();
4020            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
4021            crate::motions::move_screen_down(&mut ed.buffer, &folds, &v, count, &mut ed.sticky_col);
4022            ed.push_buffer_cursor_to_textarea();
4023        }
4024        Motion::WordFwd => {
4025            crate::motions::move_word_fwd(&mut ed.buffer, false, count, &ed.settings.iskeyword);
4026            ed.push_buffer_cursor_to_textarea();
4027        }
4028        Motion::WordBack => {
4029            crate::motions::move_word_back(&mut ed.buffer, false, count, &ed.settings.iskeyword);
4030            ed.push_buffer_cursor_to_textarea();
4031        }
4032        Motion::WordEnd => {
4033            crate::motions::move_word_end(&mut ed.buffer, false, count, &ed.settings.iskeyword);
4034            ed.push_buffer_cursor_to_textarea();
4035        }
4036        Motion::BigWordFwd => {
4037            crate::motions::move_word_fwd(&mut ed.buffer, true, count, &ed.settings.iskeyword);
4038            ed.push_buffer_cursor_to_textarea();
4039        }
4040        Motion::BigWordBack => {
4041            crate::motions::move_word_back(&mut ed.buffer, true, count, &ed.settings.iskeyword);
4042            ed.push_buffer_cursor_to_textarea();
4043        }
4044        Motion::BigWordEnd => {
4045            crate::motions::move_word_end(&mut ed.buffer, true, count, &ed.settings.iskeyword);
4046            ed.push_buffer_cursor_to_textarea();
4047        }
4048        Motion::WordEndBack => {
4049            crate::motions::move_word_end_back(
4050                &mut ed.buffer,
4051                false,
4052                count,
4053                &ed.settings.iskeyword,
4054            );
4055            ed.push_buffer_cursor_to_textarea();
4056        }
4057        Motion::BigWordEndBack => {
4058            crate::motions::move_word_end_back(&mut ed.buffer, true, count, &ed.settings.iskeyword);
4059            ed.push_buffer_cursor_to_textarea();
4060        }
4061        Motion::LineStart => {
4062            crate::motions::move_line_start(&mut ed.buffer);
4063            ed.push_buffer_cursor_to_textarea();
4064        }
4065        Motion::FirstNonBlank => {
4066            crate::motions::move_first_non_blank(&mut ed.buffer);
4067            ed.push_buffer_cursor_to_textarea();
4068        }
4069        Motion::LineEnd => {
4070            // Vim normal-mode `$` lands on the last char, not one past it.
4071            crate::motions::move_line_end(&mut ed.buffer);
4072            ed.push_buffer_cursor_to_textarea();
4073        }
4074        Motion::FileTop => {
4075            // `count gg` jumps to line `count` (first non-blank);
4076            // bare `gg` lands at the top.
4077            if count > 1 {
4078                crate::motions::move_bottom(&mut ed.buffer, count);
4079            } else {
4080                crate::motions::move_top(&mut ed.buffer);
4081            }
4082            ed.push_buffer_cursor_to_textarea();
4083        }
4084        Motion::FileBottom => {
4085            // `count G` jumps to line `count`; bare `G` lands at
4086            // the buffer bottom (`Buffer::move_bottom(0)`).
4087            if count > 1 {
4088                crate::motions::move_bottom(&mut ed.buffer, count);
4089            } else {
4090                crate::motions::move_bottom(&mut ed.buffer, 0);
4091            }
4092            ed.push_buffer_cursor_to_textarea();
4093        }
4094        Motion::Find { ch, forward, till } => {
4095            for _ in 0..count {
4096                if !find_char_on_line(ed, *ch, *forward, *till) {
4097                    break;
4098                }
4099            }
4100        }
4101        Motion::FindRepeat { .. } => {} // already resolved upstream
4102        Motion::MatchBracket => {
4103            let _ = matching_bracket(ed);
4104        }
4105        Motion::UnmatchedBracket { forward, open } => {
4106            goto_unmatched_bracket(ed, *forward, *open, count);
4107        }
4108        Motion::WordAtCursor {
4109            forward,
4110            whole_word,
4111        } => {
4112            word_at_cursor_search(ed, *forward, *whole_word, count);
4113        }
4114        Motion::SearchNext { reverse } => {
4115            // Re-push the last query so the buffer's search state is
4116            // correct even if the host happened to clear it (e.g. while
4117            // a Visual mode draw was in progress).
4118            if let Some(pattern) = ed.vim.last_search.clone() {
4119                ed.push_search_pattern(&pattern);
4120            }
4121            if ed.search_state().pattern.is_none() {
4122                return;
4123            }
4124            // `n` repeats the last search in its committed direction;
4125            // `N` inverts. So a `?` search makes `n` walk backward and
4126            // `N` walk forward.
4127            let forward = ed.vim.last_search_forward != *reverse;
4128            for _ in 0..count.max(1) {
4129                if forward {
4130                    ed.search_advance_forward(true);
4131                } else {
4132                    ed.search_advance_backward(true);
4133                }
4134            }
4135            ed.push_buffer_cursor_to_textarea();
4136        }
4137        Motion::ViewportTop => {
4138            let v = *ed.host().viewport();
4139            crate::motions::move_viewport_top(&mut ed.buffer, &v, count.saturating_sub(1));
4140            ed.push_buffer_cursor_to_textarea();
4141        }
4142        Motion::ViewportMiddle => {
4143            let v = *ed.host().viewport();
4144            crate::motions::move_viewport_middle(&mut ed.buffer, &v);
4145            ed.push_buffer_cursor_to_textarea();
4146        }
4147        Motion::ViewportBottom => {
4148            let v = *ed.host().viewport();
4149            crate::motions::move_viewport_bottom(&mut ed.buffer, &v, count.saturating_sub(1));
4150            ed.push_buffer_cursor_to_textarea();
4151        }
4152        Motion::LastNonBlank => {
4153            crate::motions::move_last_non_blank(&mut ed.buffer);
4154            ed.push_buffer_cursor_to_textarea();
4155        }
4156        Motion::LineMiddle => {
4157            let row = ed.cursor().0;
4158            let line_chars = buf_line_chars(&ed.buffer, row);
4159            // Vim's `gM`: column = floor(chars / 2). Empty / single-char
4160            // lines stay at col 0.
4161            let target = line_chars / 2;
4162            ed.jump_cursor(row, target);
4163        }
4164        Motion::ScreenLineMiddle => {
4165            // Vim's `gm`: middle of the *screen* line = column
4166            // `viewport_width / 2`, clamped to the last char of the line.
4167            let row = ed.cursor().0;
4168            let width = ed.host().viewport().width as usize;
4169            let last = buf_line_chars(&ed.buffer, row).saturating_sub(1);
4170            let target = (width / 2).min(last);
4171            ed.jump_cursor(row, target);
4172        }
4173        Motion::ParagraphPrev => {
4174            crate::motions::move_paragraph_prev(&mut ed.buffer, count);
4175            ed.push_buffer_cursor_to_textarea();
4176        }
4177        Motion::ParagraphNext => {
4178            crate::motions::move_paragraph_next(&mut ed.buffer, count);
4179            ed.push_buffer_cursor_to_textarea();
4180        }
4181        Motion::SentencePrev => {
4182            for _ in 0..count.max(1) {
4183                if let Some((row, col)) = sentence_boundary(ed, false) {
4184                    ed.jump_cursor(row, col);
4185                }
4186            }
4187        }
4188        Motion::SentenceNext => {
4189            for _ in 0..count.max(1) {
4190                if let Some((row, col)) = sentence_boundary(ed, true) {
4191                    ed.jump_cursor(row, col);
4192                }
4193            }
4194        }
4195        Motion::SectionBackward => {
4196            crate::motions::move_section_backward(&mut ed.buffer, count);
4197            ed.push_buffer_cursor_to_textarea();
4198        }
4199        Motion::SectionForward => {
4200            crate::motions::move_section_forward(&mut ed.buffer, count);
4201            ed.push_buffer_cursor_to_textarea();
4202        }
4203        Motion::SectionEndBackward => {
4204            crate::motions::move_section_end_backward(&mut ed.buffer, count);
4205            ed.push_buffer_cursor_to_textarea();
4206        }
4207        Motion::SectionEndForward => {
4208            crate::motions::move_section_end_forward(&mut ed.buffer, count);
4209            ed.push_buffer_cursor_to_textarea();
4210        }
4211        Motion::FirstNonBlankNextLine => {
4212            crate::motions::move_first_non_blank_next_line(&mut ed.buffer, count);
4213            ed.push_buffer_cursor_to_textarea();
4214        }
4215        Motion::FirstNonBlankPrevLine => {
4216            crate::motions::move_first_non_blank_prev_line(&mut ed.buffer, count);
4217            ed.push_buffer_cursor_to_textarea();
4218        }
4219        Motion::FirstNonBlankLine => {
4220            crate::motions::move_first_non_blank_line(&mut ed.buffer, count);
4221            ed.push_buffer_cursor_to_textarea();
4222        }
4223        Motion::GotoColumn => {
4224            crate::motions::move_goto_column(&mut ed.buffer, count);
4225            ed.push_buffer_cursor_to_textarea();
4226        }
4227    }
4228}
4229
4230fn move_first_non_whitespace<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
4231    // Some call sites invoke this right after `dd` / `<<` / `>>` etc
4232    // mutates the textarea content, so the migration buffer hasn't
4233    // seen the new lines OR new cursor yet. Mirror the full content
4234    // across before delegating, then push the result back so the
4235    // textarea reflects the resolved column too.
4236    ed.sync_buffer_content_from_textarea();
4237    crate::motions::move_first_non_blank(&mut ed.buffer);
4238    ed.push_buffer_cursor_to_textarea();
4239}
4240
4241fn find_char_on_line<H: crate::types::Host>(
4242    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4243    ch: char,
4244    forward: bool,
4245    till: bool,
4246) -> bool {
4247    let moved = crate::motions::find_char_on_line(&mut ed.buffer, ch, forward, till);
4248    if moved {
4249        ed.push_buffer_cursor_to_textarea();
4250    }
4251    moved
4252}
4253
4254fn matching_bracket<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) -> bool {
4255    let moved = crate::motions::match_bracket(&mut ed.buffer);
4256    if moved {
4257        ed.push_buffer_cursor_to_textarea();
4258    }
4259    moved
4260}
4261
4262/// `[(` / `])` / `[{` / `]}` — move to the `count`-th previous (`forward =
4263/// false`) / next (`forward = true`) unmatched bracket of the kind given by
4264/// `open` (`(` or `{`). Balanced inner pairs are skipped via a depth counter.
4265fn goto_unmatched_bracket<H: crate::types::Host>(
4266    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4267    forward: bool,
4268    open: char,
4269    count: usize,
4270) {
4271    let close = match open {
4272        '(' => ')',
4273        '{' => '}',
4274        _ => return,
4275    };
4276    let cursor = buf_cursor_pos(&ed.buffer);
4277    let rows = buf_row_count(&ed.buffer);
4278    let target = count.max(1);
4279    let mut found = 0usize;
4280    let mut depth = 0i32;
4281
4282    if forward {
4283        let mut r = cursor.row;
4284        let mut from_col = cursor.col + 1;
4285        while r < rows {
4286            let line: Vec<char> = buf_line(&ed.buffer, r)
4287                .unwrap_or_default()
4288                .chars()
4289                .collect();
4290            let mut ci = from_col;
4291            while ci < line.len() {
4292                let ch = line[ci];
4293                if ch == open {
4294                    depth += 1;
4295                } else if ch == close {
4296                    if depth == 0 {
4297                        found += 1;
4298                        if found == target {
4299                            buf_set_cursor_rc(&mut ed.buffer, r, ci);
4300                            ed.push_buffer_cursor_to_textarea();
4301                            return;
4302                        }
4303                    } else {
4304                        depth -= 1;
4305                    }
4306                }
4307                ci += 1;
4308            }
4309            r += 1;
4310            from_col = 0;
4311        }
4312    } else {
4313        let mut r = cursor.row as isize;
4314        // First row scans from the column left of the cursor; earlier rows from
4315        // their last column (`isize::MAX` clamps to `len - 1`).
4316        let mut from_col = cursor.col as isize - 1;
4317        while r >= 0 {
4318            let line: Vec<char> = buf_line(&ed.buffer, r as usize)
4319                .unwrap_or_default()
4320                .chars()
4321                .collect();
4322            let mut ci = from_col.min(line.len() as isize - 1);
4323            while ci >= 0 {
4324                let ch = line[ci as usize];
4325                if ch == close {
4326                    depth += 1;
4327                } else if ch == open {
4328                    if depth == 0 {
4329                        found += 1;
4330                        if found == target {
4331                            buf_set_cursor_rc(&mut ed.buffer, r as usize, ci as usize);
4332                            ed.push_buffer_cursor_to_textarea();
4333                            return;
4334                        }
4335                    } else {
4336                        depth -= 1;
4337                    }
4338                }
4339                ci -= 1;
4340            }
4341            r -= 1;
4342            from_col = isize::MAX;
4343        }
4344    }
4345}
4346
4347fn word_at_cursor_search<H: crate::types::Host>(
4348    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4349    forward: bool,
4350    whole_word: bool,
4351    count: usize,
4352) {
4353    let (row, col) = ed.cursor();
4354    let line: String = buf_line(&ed.buffer, row).unwrap_or_default();
4355    let chars: Vec<char> = line.chars().collect();
4356    if chars.is_empty() {
4357        return;
4358    }
4359    // Expand around cursor to a word boundary.
4360    let spec = ed.settings().iskeyword.clone();
4361    let is_word = |c: char| is_keyword_char(c, &spec);
4362    let mut start = col.min(chars.len().saturating_sub(1));
4363    while start > 0 && is_word(chars[start - 1]) {
4364        start -= 1;
4365    }
4366    let mut end = start;
4367    while end < chars.len() && is_word(chars[end]) {
4368        end += 1;
4369    }
4370    if end <= start {
4371        return;
4372    }
4373    let word: String = chars[start..end].iter().collect();
4374    let escaped = regex_escape(&word);
4375    let pattern = if whole_word {
4376        format!(r"\b{escaped}\b")
4377    } else {
4378        escaped
4379    };
4380    ed.push_search_pattern(&pattern);
4381    if ed.search_state().pattern.is_none() {
4382        return;
4383    }
4384    // Remember the query so `n` / `N` keep working after the jump.
4385    ed.vim.last_search = Some(pattern);
4386    ed.vim.last_search_forward = forward;
4387    for _ in 0..count.max(1) {
4388        if forward {
4389            ed.search_advance_forward(true);
4390        } else {
4391            ed.search_advance_backward(true);
4392        }
4393    }
4394    ed.push_buffer_cursor_to_textarea();
4395}
4396
4397fn regex_escape(s: &str) -> String {
4398    let mut out = String::with_capacity(s.len());
4399    for c in s.chars() {
4400        if matches!(
4401            c,
4402            '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\'
4403        ) {
4404            out.push('\\');
4405        }
4406        out.push(c);
4407    }
4408    out
4409}
4410
4411// ─── Operator application ──────────────────────────────────────────────────
4412
4413/// Public(crate) entry: apply operator over the motion identified by a raw
4414/// char key. Called by `Editor::apply_op_motion` (the public controller API)
4415/// so the hjkl-vim pending-state reducer can dispatch `ApplyOpMotion` without
4416/// re-entering the FSM.
4417///
4418/// Applies standard vim quirks:
4419/// - `cw` / `cW` → `ce` / `cE`
4420/// - `FindRepeat` → resolves against `last_find`
4421/// - Updates `last_find` and `last_change` per existing conventions.
4422///
4423/// No-op when `motion_key` does not produce a known motion.
4424pub(crate) fn apply_op_motion_key<H: crate::types::Host>(
4425    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4426    op: Operator,
4427    motion_key: char,
4428    total_count: usize,
4429) {
4430    let input = Input {
4431        key: Key::Char(motion_key),
4432        ctrl: false,
4433        alt: false,
4434        shift: false,
4435    };
4436    let Some(motion) = parse_motion(&input) else {
4437        return;
4438    };
4439    let motion = match motion {
4440        Motion::FindRepeat { reverse } => match ed.vim.last_find {
4441            Some((ch, forward, till)) => Motion::Find {
4442                ch,
4443                forward: if reverse { !forward } else { forward },
4444                till,
4445            },
4446            None => return,
4447        },
4448        // Vim quirk: `cw` / `cW` → `ce` / `cE`.
4449        Motion::WordFwd if op == Operator::Change => Motion::WordEnd,
4450        Motion::BigWordFwd if op == Operator::Change => Motion::BigWordEnd,
4451        m => m,
4452    };
4453    apply_op_with_motion(ed, op, &motion, total_count);
4454    if let Motion::Find { ch, forward, till } = &motion {
4455        ed.vim.last_find = Some((*ch, *forward, *till));
4456    }
4457    if !ed.vim.replaying && op_is_change(op) {
4458        ed.vim.last_change = Some(LastChange::OpMotion {
4459            op,
4460            motion,
4461            count: total_count,
4462            inserted: None,
4463        });
4464    }
4465}
4466
4467/// Public(crate) entry: apply doubled-letter line op (`dd`/`yy`/`cc`/`>>`/`<<`/`gcc`).
4468/// Called by `Editor::apply_op_double` (the public controller API).
4469pub(crate) fn apply_op_double<H: crate::types::Host>(
4470    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4471    op: Operator,
4472    total_count: usize,
4473) {
4474    if op == Operator::Comment {
4475        // `gcc` / `{N}gcc` — toggle comment on `total_count` lines starting at cursor.
4476        let row = buf_cursor_pos(&ed.buffer).row;
4477        let end_row = (row + total_count.max(1) - 1).min(ed.buffer.row_count().saturating_sub(1));
4478        ed.toggle_comment_range(row, end_row);
4479        ed.vim.mode = Mode::Normal;
4480        if !ed.vim.replaying {
4481            ed.vim.last_change = Some(LastChange::LineOp {
4482                op,
4483                count: total_count,
4484                inserted: None,
4485            });
4486        }
4487        return;
4488    }
4489    execute_line_op(ed, op, total_count);
4490    if !ed.vim.replaying {
4491        ed.vim.last_change = Some(LastChange::LineOp {
4492            op,
4493            count: total_count,
4494            inserted: None,
4495        });
4496    }
4497}
4498
4499/// Compute the `gn` / `gN` target match as a `(start, end_inclusive)` pair.
4500/// When the cursor sits inside a match, that match is the target; otherwise the
4501/// next match (forward) or previous match (backward) is used. Returns `None`
4502/// when there is no pattern or no match remains.
4503fn gn_find_range<H: crate::types::Host>(
4504    ed: &Editor<hjkl_buffer::Buffer, H>,
4505    re: &regex::Regex,
4506    forward: bool,
4507) -> Option<(crate::types::Pos, crate::types::Pos)> {
4508    use crate::types::{Cursor, Pos, Search};
4509    let cursor = Cursor::cursor(&ed.buffer);
4510    let contains =
4511        Search::find_prev(&ed.buffer, cursor, re).filter(|m| m.start <= cursor && cursor < m.end);
4512    let range = if let Some(m) = contains {
4513        m
4514    } else if forward {
4515        Search::find_next(&ed.buffer, cursor, re)?
4516    } else {
4517        Search::find_prev(&ed.buffer, cursor, re)?
4518    };
4519    let end_incl = if range.end.col > 0 {
4520        Pos::new(range.end.line, range.end.col - 1)
4521    } else {
4522        range.end
4523    };
4524    Some((range.start, end_incl))
4525}
4526
4527/// `gn` / `gN` — operate on (or select) the search match. `op = None` enters
4528/// Visual mode with the match selected; `Some(op)` applies the operator to the
4529/// match as a charwise inclusive range. Records `LastChange::GnOp` so `cgn` /
4530/// `dgn` are `.`-repeatable.
4531pub(crate) fn gn_operate<H: crate::types::Host>(
4532    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4533    op: Option<Operator>,
4534    forward: bool,
4535    count: usize,
4536) {
4537    use crate::types::{Cursor, Pos};
4538    // Make sure the compiled pattern reflects the last `/` or `*` search.
4539    if let Some(p) = ed.vim.last_search.clone() {
4540        ed.push_search_pattern(&p);
4541    }
4542    let Some(re) = ed.search_state().pattern.clone() else {
4543        return;
4544    };
4545    ed.sync_buffer_content_from_textarea();
4546
4547    let Some(mut range) = gn_find_range(ed, &re, forward) else {
4548        return;
4549    };
4550    // `[count]gn` walks to the count-th match.
4551    for _ in 1..count.max(1) {
4552        let past = Pos::new(range.1.line, range.1.col + 1);
4553        Cursor::set_cursor(&mut ed.buffer, past);
4554        match gn_find_range(ed, &re, forward) {
4555            Some(r) => range = r,
4556            None => break,
4557        }
4558    }
4559    let start_t = (range.0.line as usize, range.0.col as usize);
4560    let end_t = (range.1.line as usize, range.1.col as usize);
4561
4562    match op {
4563        None => {
4564            // Bare `gn` — select the match in Visual mode.
4565            ed.vim.visual_anchor = start_t;
4566            buf_set_cursor_rc(&mut ed.buffer, end_t.0, end_t.1);
4567            ed.vim.mode = Mode::Visual;
4568            ed.vim.current_mode = crate::VimMode::Visual;
4569            ed.push_buffer_cursor_to_textarea();
4570        }
4571        Some(Operator::Delete) => {
4572            ed.push_undo();
4573            cut_vim_range(ed, start_t, end_t, RangeKind::Inclusive);
4574            // Deleting at the line end can leave the cursor one past the last
4575            // char; vim clamps it back onto the line.
4576            clamp_cursor_to_normal_mode(ed);
4577            ed.push_buffer_cursor_to_textarea();
4578            if !ed.vim.replaying {
4579                ed.vim.last_change = Some(LastChange::GnOp {
4580                    op: Operator::Delete,
4581                    forward,
4582                    inserted: None,
4583                });
4584            }
4585        }
4586        Some(Operator::Change) => {
4587            ed.push_undo();
4588            ed.vim.change_mark_start = Some(start_t);
4589            cut_vim_range(ed, start_t, end_t, RangeKind::Inclusive);
4590            if !ed.vim.replaying {
4591                ed.vim.last_change = Some(LastChange::GnOp {
4592                    op: Operator::Change,
4593                    forward,
4594                    inserted: None,
4595                });
4596            }
4597            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
4598        }
4599        Some(Operator::Yank) => {
4600            let text = read_vim_range(ed, start_t, end_t, RangeKind::Inclusive);
4601            if !text.is_empty() {
4602                ed.record_yank_to_host(text.clone());
4603                ed.record_yank(text, false);
4604            }
4605            buf_set_cursor_rc(&mut ed.buffer, start_t.0, start_t.1);
4606            ed.push_buffer_cursor_to_textarea();
4607        }
4608        Some(other @ (Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase)) => {
4609            // Case op over a gn match: apply as a charwise op over the
4610            // inclusive range.
4611            ed.push_undo();
4612            apply_case_op_to_selection(ed, other, start_t, end_t, RangeKind::Inclusive);
4613        }
4614        Some(_) => {}
4615    }
4616}
4617
4618/// Shared implementation: apply operator over a g-chord motion or case-op
4619/// linewise form. Called by `Editor::apply_op_g` (the public controller API)
4620/// so the hjkl-vim reducer can dispatch `ApplyOpG` without re-entering the FSM.
4621///
4622/// - If `op` is Uppercase/Lowercase/ToggleCase and `ch` matches the op's char
4623///   (`U`/`u`/`~`): executes the line op and updates `last_change`.
4624/// - `n` / `N` operate on the search match (`dgn` / `cgn`).
4625/// - Otherwise, maps `ch` to a motion (`g`→FileTop, `e`→WordEndBack,
4626///   `E`→BigWordEndBack, `j`→ScreenDown, `k`→ScreenUp) and applies. Unknown
4627///   chars are silently ignored (no-op), matching the engine FSM's behaviour.
4628pub(crate) fn apply_op_g_inner<H: crate::types::Host>(
4629    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4630    op: Operator,
4631    ch: char,
4632    total_count: usize,
4633) {
4634    // Case-op linewise form: `gUgU`, `gugu`, `g~g~`, `g?g?` — same effect as
4635    // `gUU` / `guu` / `g~~` / `g??`.
4636    if matches!(
4637        op,
4638        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13
4639    ) {
4640        let op_char = match op {
4641            Operator::Uppercase => 'U',
4642            Operator::Lowercase => 'u',
4643            Operator::ToggleCase => '~',
4644            Operator::Rot13 => '?',
4645            _ => unreachable!(),
4646        };
4647        if ch == op_char {
4648            execute_line_op(ed, op, total_count);
4649            if !ed.vim.replaying {
4650                ed.vim.last_change = Some(LastChange::LineOp {
4651                    op,
4652                    count: total_count,
4653                    inserted: None,
4654                });
4655            }
4656            return;
4657        }
4658    }
4659    // `dgn` / `cgn` / `ygn` (and `gN` forms) — operate on the search match.
4660    if ch == 'n' || ch == 'N' {
4661        gn_operate(ed, Some(op), ch == 'n', total_count);
4662        return;
4663    }
4664    let motion = match ch {
4665        'g' => Motion::FileTop,
4666        'e' => Motion::WordEndBack,
4667        'E' => Motion::BigWordEndBack,
4668        'j' => Motion::ScreenDown,
4669        'k' => Motion::ScreenUp,
4670        _ => return, // Unknown char — no-op.
4671    };
4672    apply_op_with_motion(ed, op, &motion, total_count);
4673    if !ed.vim.replaying && op_is_change(op) {
4674        ed.vim.last_change = Some(LastChange::OpMotion {
4675            op,
4676            motion,
4677            count: total_count,
4678            inserted: None,
4679        });
4680    }
4681}
4682
4683/// Public(crate) entry point for bare `g<x>`. Applies the g-chord effect
4684/// given the char `ch` and pre-captured `count`. Called by `Editor::after_g`
4685/// (the public controller API) so the hjkl-vim pending-state reducer can
4686/// dispatch `AfterGChord` without re-entering the FSM.
4687pub(crate) fn apply_after_g<H: crate::types::Host>(
4688    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4689    ch: char,
4690    count: usize,
4691) {
4692    match ch {
4693        'g' => {
4694            // gg — top / jump to line count.
4695            let pre = ed.cursor();
4696            if count > 1 {
4697                ed.jump_cursor(count - 1, 0);
4698            } else {
4699                ed.jump_cursor(0, 0);
4700            }
4701            move_first_non_whitespace(ed);
4702            // Update sticky_col to the first-non-blank column so j/k after
4703            // gg aim for the correct column per vim semantics.
4704            ed.sticky_col = Some(ed.cursor().1);
4705            if ed.cursor() != pre {
4706                ed.push_jump(pre);
4707            }
4708        }
4709        'e' => execute_motion(ed, Motion::WordEndBack, count),
4710        'E' => execute_motion(ed, Motion::BigWordEndBack, count),
4711        // `g_` — last non-blank on the line.
4712        '_' => execute_motion(ed, Motion::LastNonBlank, count),
4713        // `gM` — middle char column of the current line.
4714        'M' => execute_motion(ed, Motion::LineMiddle, count),
4715        // `gm` — middle of the screen line (viewport_width/2, clamped to EOL).
4716        'm' => execute_motion(ed, Motion::ScreenLineMiddle, count),
4717        // `gv` — re-enter the last visual selection.
4718        // Phase 6.6a: drive through the public Editor API.
4719        'v' => ed.reenter_last_visual(),
4720        // `gj` / `gk` — display-line down / up. Walks one screen
4721        // segment at a time under `:set wrap`; falls back to `j`/`k`
4722        // when wrap is off (Buffer::move_screen_* handles the branch).
4723        'j' => execute_motion(ed, Motion::ScreenDown, count),
4724        'k' => execute_motion(ed, Motion::ScreenUp, count),
4725        // Case operators: `gU` / `gu` / `g~`. Enter operator-pending
4726        // so the next input is treated as the motion / text object /
4727        // shorthand double (`gUU`, `guu`, `g~~`).
4728        'U' => {
4729            ed.vim.pending = Pending::Op {
4730                op: Operator::Uppercase,
4731                count1: count,
4732            };
4733        }
4734        'u' => {
4735            ed.vim.pending = Pending::Op {
4736                op: Operator::Lowercase,
4737                count1: count,
4738            };
4739        }
4740        '~' => {
4741            ed.vim.pending = Pending::Op {
4742                op: Operator::ToggleCase,
4743                count1: count,
4744            };
4745        }
4746        '?' => {
4747            // `g?{motion}` — ROT13 operator (`g??` / `g?g?` doubled).
4748            ed.vim.pending = Pending::Op {
4749                op: Operator::Rot13,
4750                count1: count,
4751            };
4752        }
4753        'q' => {
4754            // `gq{motion}` — text reflow operator. Subsequent motion
4755            // / textobj rides the same operator pipeline.
4756            ed.vim.pending = Pending::Op {
4757                op: Operator::Reflow,
4758                count1: count,
4759            };
4760        }
4761        'w' => {
4762            // `gw{motion}` — same reflow as `gq` but cursor stays at
4763            // its pre-reflow position (clamped to new EOL if shorter).
4764            ed.vim.pending = Pending::Op {
4765                op: Operator::ReflowKeepCursor,
4766                count1: count,
4767            };
4768        }
4769        'J' => {
4770            // `gJ` — join line below without inserting a space. `[count]gJ`
4771            // joins `count` lines (`count - 1` joins), like `J`.
4772            let joins = count.max(2) - 1;
4773            for _ in 0..joins {
4774                ed.push_undo();
4775                join_line_raw(ed);
4776            }
4777            if !ed.vim.replaying {
4778                ed.vim.last_change = Some(LastChange::JoinLine { count: joins });
4779            }
4780        }
4781        'd' => {
4782            // `gd` — goto definition. hjkl-engine doesn't run an LSP
4783            // itself; raise an intent the host drains and routes to
4784            // `sqls`. The cursor stays put here — the host moves it
4785            // once it has the target location.
4786            ed.pending_lsp = Some(crate::editor::LspIntent::GotoDefinition);
4787        }
4788        // `gi` — go to last-insert position and re-enter insert mode.
4789        // Matches vim's `:h gi`: moves to the `'^` mark position (the
4790        // cursor where insert mode was last active, before Esc step-back)
4791        // and enters insert mode there.
4792        'i' => {
4793            if let Some((row, col)) = ed.vim.last_insert_pos {
4794                ed.jump_cursor(row, col);
4795            }
4796            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::I));
4797        }
4798        // `gc` — enter operator-pending for the comment-toggle operator.
4799        // `gcc` (doubled 'c') is the line-wise form; `gc{motion}` is the
4800        // motion form. The operator is Comment — the app layer (or the
4801        // doubled-char path in handle_after_op) calls toggle_comment_range.
4802        'c' => {
4803            ed.vim.pending = Pending::Op {
4804                op: Operator::Comment,
4805                count1: count,
4806            };
4807        }
4808        // `gp` / `gP` — paste like `p`/`P` but leave the cursor just after
4809        // the pasted text.
4810        'p' => paste_bridge(ed, false, count.max(1), true, false),
4811        'P' => paste_bridge(ed, true, count.max(1), true, false),
4812        // `gn` / `gN` — select the next / previous search match in Visual mode.
4813        'n' => gn_operate(ed, None, true, count.max(1)),
4814        'N' => gn_operate(ed, None, false, count.max(1)),
4815        // `g;` / `g,` — walk the change list. `g;` toward older
4816        // entries, `g,` toward newer.
4817        ';' => walk_change_list(ed, -1, count.max(1)),
4818        ',' => walk_change_list(ed, 1, count.max(1)),
4819        // `g*` / `g#` — like `*` / `#` but match substrings (no `\b`
4820        // boundary anchors), so the cursor on `foo` finds it inside
4821        // `foobar` too.
4822        '*' => execute_motion(
4823            ed,
4824            Motion::WordAtCursor {
4825                forward: true,
4826                whole_word: false,
4827            },
4828            count,
4829        ),
4830        '#' => execute_motion(
4831            ed,
4832            Motion::WordAtCursor {
4833                forward: false,
4834                whole_word: false,
4835            },
4836            count,
4837        ),
4838        // `g&` — repeat last `:s` over the whole buffer (1,$), keeping all
4839        // original flags. Equivalent to `:%s//~/&` in vim.
4840        '&' => {
4841            let cmd = match ed.vim.last_substitute.clone() {
4842                Some(c) => c,
4843                None => {
4844                    // No prior substitute — mirror the `:&` error path; do
4845                    // nothing to the buffer (the host's status line will show
4846                    // the pending error if wired; for headless / test hosts
4847                    // we simply return silently).
4848                    return;
4849                }
4850            };
4851            let last_row = buf_row_count(&ed.buffer).saturating_sub(1) as u32;
4852            let r = 0u32..=last_row;
4853            // apply_substitute moves cursor to last changed line and pushes
4854            // one undo snapshot — same semantics as `:&&` / `:%s//~/&`.
4855            let _ = crate::substitute::apply_substitute(ed, &cmd, r);
4856            // Update stored substitute so subsequent `g&` sees the same cmd.
4857            // (apply_substitute doesn't call set_last_substitute itself.)
4858            ed.vim.last_substitute = Some(cmd);
4859        }
4860        _ => {}
4861    }
4862}
4863
4864/// Normal-mode `&` — repeat the last `:s` on the current line, dropping the
4865/// previous flags (vim: `&` ≡ `:s` with no flags). `g&` keeps flags + whole
4866/// buffer; this is the single-line, flag-less form.
4867pub(crate) fn ampersand_repeat<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
4868    let Some(mut cmd) = ed.vim.last_substitute.clone() else {
4869        return;
4870    };
4871    cmd.flags = crate::substitute::SubstFlags::default();
4872    let row = buf_cursor_pos(&ed.buffer).row as u32;
4873    let _ = crate::substitute::apply_substitute(ed, &cmd, row..=row);
4874}
4875
4876/// Public(crate) entry point for bare `z<x>`. Applies the z-chord effect
4877/// given the char `ch` and pre-captured `count`. Called by `Editor::after_z`
4878/// (the public controller API) so the hjkl-vim pending-state reducer can
4879/// dispatch `AfterZChord` without re-entering the engine FSM.
4880pub(crate) fn apply_after_z<H: crate::types::Host>(
4881    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4882    ch: char,
4883    count: usize,
4884) {
4885    use crate::editor::CursorScrollTarget;
4886    let row = ed.cursor().0;
4887    match ch {
4888        'z' => {
4889            ed.scroll_cursor_to(CursorScrollTarget::Center);
4890            ed.vim.viewport_pinned = true;
4891            ed.vim.scroll_anim_hint = true;
4892        }
4893        't' => {
4894            ed.scroll_cursor_to(CursorScrollTarget::Top);
4895            ed.vim.viewport_pinned = true;
4896            ed.vim.scroll_anim_hint = true;
4897        }
4898        'b' => {
4899            ed.scroll_cursor_to(CursorScrollTarget::Bottom);
4900            ed.vim.viewport_pinned = true;
4901            ed.vim.scroll_anim_hint = true;
4902        }
4903        // Folds — operate on the fold under the cursor (or the
4904        // whole buffer for `R` / `M`). Routed through
4905        // [`Editor::apply_fold_op`] (0.0.38 Patch C-δ.4) so the host
4906        // can observe / veto each op via [`Editor::take_fold_ops`].
4907        'o' => {
4908            ed.apply_fold_op(crate::types::FoldOp::OpenAt(row));
4909        }
4910        'c' => {
4911            ed.apply_fold_op(crate::types::FoldOp::CloseAt(row));
4912        }
4913        'a' => {
4914            ed.apply_fold_op(crate::types::FoldOp::ToggleAt(row));
4915        }
4916        'R' => {
4917            ed.apply_fold_op(crate::types::FoldOp::OpenAll);
4918        }
4919        'M' => {
4920            ed.apply_fold_op(crate::types::FoldOp::CloseAll);
4921        }
4922        'E' => {
4923            ed.apply_fold_op(crate::types::FoldOp::ClearAll);
4924        }
4925        'd' => {
4926            ed.apply_fold_op(crate::types::FoldOp::RemoveAt(row));
4927        }
4928        'f' => {
4929            if matches!(
4930                ed.vim.mode,
4931                Mode::Visual | Mode::VisualLine | Mode::VisualBlock
4932            ) {
4933                // `zf` over a Visual selection creates a fold spanning
4934                // anchor → cursor.
4935                let anchor_row = match ed.vim.mode {
4936                    Mode::VisualLine => ed.vim.visual_line_anchor,
4937                    Mode::VisualBlock => ed.vim.block_anchor.0,
4938                    _ => ed.vim.visual_anchor.0,
4939                };
4940                let cur = ed.cursor().0;
4941                let top = anchor_row.min(cur);
4942                let bot = anchor_row.max(cur);
4943                ed.apply_fold_op(crate::types::FoldOp::Add {
4944                    start_row: top,
4945                    end_row: bot,
4946                    closed: true,
4947                });
4948                ed.vim.mode = Mode::Normal;
4949            } else {
4950                // `zf{motion}` / `zf{textobj}` — route through the
4951                // operator pipeline. `Operator::Fold` reuses every
4952                // motion / text-object / `g`-prefix branch the other
4953                // operators get.
4954                ed.vim.pending = Pending::Op {
4955                    op: Operator::Fold,
4956                    count1: count,
4957                };
4958            }
4959        }
4960        _ => {}
4961    }
4962}
4963
4964/// Public(crate) entry point for bare `f<x>` / `F<x>` / `t<x>` / `T<x>`.
4965/// Applies the motion and records `last_find` for `;` / `,` repeat.
4966/// Called by `Editor::find_char` (the public controller API) so the
4967/// hjkl-vim pending-state reducer can dispatch `FindChar` without
4968/// re-entering the FSM.
4969pub(crate) fn apply_find_char<H: crate::types::Host>(
4970    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4971    ch: char,
4972    forward: bool,
4973    till: bool,
4974    count: usize,
4975) {
4976    execute_motion(ed, Motion::Find { ch, forward, till }, count.max(1));
4977    ed.vim.last_find = Some((ch, forward, till));
4978    ed.vim.last_horizontal_motion = LastHorizontalMotion::FindChar;
4979}
4980
4981// ─── Sneak motion ──────────────────────────────────────────────────────────
4982
4983/// Scan the buffer from the current cursor position for the `count`-th
4984/// occurrence of the two-char digraph `(c1, c2)`.
4985///
4986/// - `forward=true` → scan downward (rows) and rightward (cols) past cursor.
4987/// - `forward=false` → scan upward and leftward.
4988///
4989/// When a match is found the cursor jumps to the first char of the digraph.
4990/// `last_sneak` and `last_horizontal_motion` are updated so `;`/`,` repeat.
4991/// No-op (cursor unchanged) when no match exists.
4992pub(crate) fn apply_sneak<H: crate::types::Host>(
4993    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4994    c1: char,
4995    c2: char,
4996    forward: bool,
4997    count: usize,
4998) {
4999    let count = count.max(1);
5000    let (start_row, start_col) = ed.cursor();
5001    let row_count = buf_row_count(&ed.buffer);
5002
5003    let result = if forward {
5004        sneak_scan_forward(ed, start_row, start_col, c1, c2, count)
5005    } else {
5006        sneak_scan_backward(ed, start_row, start_col, c1, c2, count)
5007    };
5008
5009    if let Some((row, col)) = result {
5010        buf_set_cursor_rc(&mut ed.buffer, row, col);
5011        ed.push_buffer_cursor_to_textarea();
5012        let _ = row_count; // suppress unused-variable warning
5013    }
5014
5015    ed.vim.last_sneak = Some(((c1, c2), forward));
5016    ed.vim.last_horizontal_motion = LastHorizontalMotion::Sneak;
5017}
5018
5019/// Scan forward from `(start_row, start_col)` (exclusive — start right after
5020/// cursor) for the `count`-th occurrence of `c1+c2`.
5021fn sneak_scan_forward<H: crate::types::Host>(
5022    ed: &Editor<hjkl_buffer::Buffer, H>,
5023    start_row: usize,
5024    start_col: usize,
5025    c1: char,
5026    c2: char,
5027    count: usize,
5028) -> Option<(usize, usize)> {
5029    let row_count = buf_row_count(&ed.buffer);
5030    let mut hits = 0usize;
5031    for row in start_row..row_count {
5032        let line = buf_line(&ed.buffer, row).unwrap_or_default();
5033        let chars: Vec<char> = line.chars().collect();
5034        // On the start row begin scanning one past the current column.
5035        let col_start = if row == start_row { start_col + 1 } else { 0 };
5036        if col_start + 1 > chars.len() {
5037            continue;
5038        }
5039        for col in col_start..chars.len().saturating_sub(1) {
5040            if chars[col] == c1 && chars[col + 1] == c2 {
5041                hits += 1;
5042                if hits == count {
5043                    return Some((row, col));
5044                }
5045            }
5046        }
5047    }
5048    None
5049}
5050
5051/// Scan backward from `(start_row, start_col)` (exclusive — start left of
5052/// cursor) for the `count`-th occurrence of `c1+c2`.
5053fn sneak_scan_backward<H: crate::types::Host>(
5054    ed: &Editor<hjkl_buffer::Buffer, H>,
5055    start_row: usize,
5056    start_col: usize,
5057    c1: char,
5058    c2: char,
5059    count: usize,
5060) -> Option<(usize, usize)> {
5061    let row_count = buf_row_count(&ed.buffer);
5062    let mut hits = 0usize;
5063    // Iterate rows from start_row down to 0.
5064    let rows_to_scan = (0..row_count).rev().skip(row_count - start_row - 1);
5065    for row in rows_to_scan {
5066        let line = buf_line(&ed.buffer, row).unwrap_or_default();
5067        let chars: Vec<char> = line.chars().collect();
5068        // On the start row end scanning one before the current column.
5069        let col_end = if row == start_row {
5070            start_col.saturating_sub(1)
5071        } else if chars.is_empty() {
5072            continue;
5073        } else {
5074            chars.len().saturating_sub(1)
5075        };
5076        if col_end == 0 {
5077            continue;
5078        }
5079        // Scan cols right-to-left from col_end-1 so we match c1 at col, c2 at col+1.
5080        for col in (0..col_end).rev() {
5081            if col + 1 < chars.len() && chars[col] == c1 && chars[col + 1] == c2 {
5082                hits += 1;
5083                if hits == count {
5084                    return Some((row, col));
5085                }
5086            }
5087        }
5088    }
5089    None
5090}
5091
5092/// Apply `op` over the sneak digraph range. Charwise exclusive from cursor up
5093/// to (but not including) the first char of the first match. This matches
5094/// vim-sneak's default `<Plug>Sneak_s` operator-pending behavior.
5095///
5096/// Example: buffer `"foo ab bar\n"`, cursor col 0, `dsab` → deletes `"foo "`
5097/// leaving `"ab bar\n"`.
5098pub(crate) fn apply_op_sneak<H: crate::types::Host>(
5099    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5100    op: Operator,
5101    c1: char,
5102    c2: char,
5103    forward: bool,
5104    total_count: usize,
5105) {
5106    let start = ed.cursor();
5107    let result = if forward {
5108        sneak_scan_forward(ed, start.0, start.1, c1, c2, total_count)
5109    } else {
5110        sneak_scan_backward(ed, start.0, start.1, c1, c2, total_count)
5111    };
5112    let Some(end) = result else {
5113        return;
5114    };
5115    // Charwise exclusive — land the virtual cursor at end, then use
5116    // Exclusive range kind (end position not included).
5117    ed.jump_cursor(end.0, end.1);
5118    let end_cur = ed.cursor();
5119    ed.jump_cursor(start.0, start.1);
5120    run_operator_over_range(ed, op, start, end_cur, RangeKind::Exclusive);
5121    ed.vim.last_sneak = Some(((c1, c2), forward));
5122    ed.vim.last_horizontal_motion = LastHorizontalMotion::Sneak;
5123    if !ed.vim.replaying && op_is_change(op) {
5124        // No dot-repeat motion variant for sneak ops (plugin behavior,
5125        // not vim-core); record as a Change/Delete line op as a
5126        // best-effort fallback so `.` at least does something.
5127    }
5128}
5129
5130/// Public(crate) entry: apply operator over a find motion (`df<x>` etc.).
5131/// Called by `Editor::apply_op_find` (the public controller API) so the
5132/// hjkl-vim `PendingState::OpFind` reducer can dispatch `ApplyOpFind` without
5133/// re-entering the FSM. `handle_op_find_target` now delegates here to avoid
5134/// logic duplication.
5135pub(crate) fn apply_op_find_motion<H: crate::types::Host>(
5136    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5137    op: Operator,
5138    ch: char,
5139    forward: bool,
5140    till: bool,
5141    total_count: usize,
5142) {
5143    let motion = Motion::Find { ch, forward, till };
5144    apply_op_with_motion(ed, op, &motion, total_count);
5145    ed.vim.last_find = Some((ch, forward, till));
5146    if !ed.vim.replaying && op_is_change(op) {
5147        ed.vim.last_change = Some(LastChange::OpMotion {
5148            op,
5149            motion,
5150            count: total_count,
5151            inserted: None,
5152        });
5153    }
5154}
5155
5156/// Shared implementation: map `ch` to `TextObject`, apply the operator, and
5157/// record `last_change`. Returns `false` when `ch` is not a known text-object
5158/// kind (caller should treat as a no-op). Called by `Editor::apply_op_text_obj`
5159/// (the public controller API) so hjkl-vim can dispatch without re-entering the FSM.
5160///
5161/// `_total_count` is accepted for API symmetry with `apply_op_find_motion` /
5162/// `apply_op_motion_key` but is currently unused — text objects don't repeat
5163/// in vim's current grammar. Kept for future-proofing.
5164pub(crate) fn apply_op_text_obj_inner<H: crate::types::Host>(
5165    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5166    op: Operator,
5167    ch: char,
5168    inner: bool,
5169    total_count: usize,
5170) -> bool {
5171    // `total_count` drives bracket text objects: `2di{` targets the Nth
5172    // enclosing pair. Non-bracket objects ignore it (vim does too).
5173    let obj = match ch {
5174        'w' => TextObject::Word { big: false },
5175        'W' => TextObject::Word { big: true },
5176        '"' | '\'' | '`' => TextObject::Quote(ch),
5177        '(' | ')' | 'b' => TextObject::Bracket('('),
5178        '[' | ']' => TextObject::Bracket('['),
5179        '{' | '}' | 'B' => TextObject::Bracket('{'),
5180        '<' | '>' => TextObject::Bracket('<'),
5181        'p' => TextObject::Paragraph,
5182        't' => TextObject::XmlTag,
5183        's' => TextObject::Sentence,
5184        _ => return false,
5185    };
5186    apply_op_with_text_object(ed, op, obj, inner, total_count.max(1));
5187    if !ed.vim.replaying && op_is_change(op) {
5188        ed.vim.last_change = Some(LastChange::OpTextObj {
5189            op,
5190            obj,
5191            inner,
5192            inserted: None,
5193        });
5194    }
5195    true
5196}
5197
5198/// Move `pos` back by one character, clamped to (0, 0).
5199pub(crate) fn retreat_one<H: crate::types::Host>(
5200    ed: &Editor<hjkl_buffer::Buffer, H>,
5201    pos: (usize, usize),
5202) -> (usize, usize) {
5203    let (r, c) = pos;
5204    if c > 0 {
5205        (r, c - 1)
5206    } else if r > 0 {
5207        let prev_len = buf_line_bytes(&ed.buffer, r - 1);
5208        (r - 1, prev_len)
5209    } else {
5210        (0, 0)
5211    }
5212}
5213
5214/// Variant of begin_insert that doesn't push_undo (caller already did).
5215fn begin_insert_noundo<H: crate::types::Host>(
5216    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5217    count: usize,
5218    reason: InsertReason,
5219) {
5220    let reason = if ed.vim.replaying {
5221        InsertReason::ReplayOnly
5222    } else {
5223        reason
5224    };
5225    let (row, col) = ed.cursor();
5226    ed.vim.insert_session = Some(InsertSession {
5227        count,
5228        row_min: row,
5229        row_max: row,
5230        before_rope: crate::types::Query::rope(&ed.buffer),
5231        reason,
5232        start_row: row,
5233        start_col: col,
5234    });
5235    ed.vim.mode = Mode::Insert;
5236    // Phase 6.3: keep current_mode in sync for callers that bypass step().
5237    ed.vim.current_mode = crate::VimMode::Insert;
5238    drop_blame_if_left_normal(ed);
5239}
5240
5241// ─── Operator × Motion application ─────────────────────────────────────────
5242
5243pub(crate) fn apply_op_with_motion<H: crate::types::Host>(
5244    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5245    op: Operator,
5246    motion: &Motion,
5247    count: usize,
5248) {
5249    let start = ed.cursor();
5250    // Tentatively apply motion to find the endpoint. Operator context
5251    // so `l` on the last char advances past-last (standard vim
5252    // exclusive-motion endpoint behaviour), enabling `dl` / `cl` /
5253    // `yl` to cover the final char.
5254    apply_motion_cursor_ctx(ed, motion, count, true);
5255    let end = ed.cursor();
5256    let kind = motion_kind(motion);
5257    // Restore cursor before selecting (so Yank leaves cursor at start).
5258    ed.jump_cursor(start.0, start.1);
5259
5260    // Comment is always linewise regardless of motion kind — toggle rows.
5261    if op == Operator::Comment {
5262        let top = start.0.min(end.0);
5263        let bot = start.0.max(end.0);
5264        ed.toggle_comment_range(top, bot);
5265        ed.vim.mode = Mode::Normal;
5266        return;
5267    }
5268
5269    run_operator_over_range(ed, op, start, end, kind);
5270}
5271
5272fn apply_op_with_text_object<H: crate::types::Host>(
5273    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5274    op: Operator,
5275    obj: TextObject,
5276    inner: bool,
5277    count: usize,
5278) {
5279    let Some((mut start, mut end, mut kind)) = text_object_range(ed, obj, inner, count) else {
5280        return;
5281    };
5282    // vim's exclusive-motion adjustment (`:h exclusive`), applied to the
5283    // OPERATOR form of an inner bracket object spanning multiple lines (the
5284    // visual form keeps the raw charwise region). When the exclusive end sits
5285    // in column 0, pull it back to the end of the previous line and make the
5286    // motion inclusive; if the start is at or before the first non-blank of its
5287    // line, promote to linewise. This is what makes `di{` on a contentful
5288    // multi-line block collapse to bare braces ("{\n}") and a clean block
5289    // delete its body linewise.
5290    if inner
5291        && matches!(obj, TextObject::Bracket(_))
5292        && kind == RangeKind::Exclusive
5293        && end.0 > start.0
5294        && end.1 == 0
5295    {
5296        let prev = end.0 - 1;
5297        let prev_len = buf_line_chars(&ed.buffer, prev);
5298        let fnb = buf_line(&ed.buffer, start.0)
5299            .unwrap_or_default()
5300            .chars()
5301            .take_while(|c| *c == ' ' || *c == '\t')
5302            .count();
5303        if start.1 <= fnb {
5304            start = (start.0, 0);
5305            end = (prev, prev_len);
5306            kind = RangeKind::Linewise;
5307        } else {
5308            end = (prev, prev_len.saturating_sub(1));
5309            kind = RangeKind::Inclusive;
5310        }
5311    }
5312    ed.jump_cursor(start.0, start.1);
5313    run_operator_over_range(ed, op, start, end, kind);
5314}
5315
5316fn motion_kind(motion: &Motion) -> RangeKind {
5317    match motion {
5318        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown => RangeKind::Linewise,
5319        Motion::FileTop | Motion::FileBottom => RangeKind::Linewise,
5320        Motion::ViewportTop | Motion::ViewportMiddle | Motion::ViewportBottom => {
5321            RangeKind::Linewise
5322        }
5323        Motion::WordEnd | Motion::BigWordEnd | Motion::WordEndBack | Motion::BigWordEndBack => {
5324            RangeKind::Inclusive
5325        }
5326        Motion::Find { .. } => RangeKind::Inclusive,
5327        Motion::MatchBracket => RangeKind::Inclusive,
5328        // `[(` / `])` etc. are exclusive: `d])` deletes up to but not including
5329        // the bracket; `d[(` deletes back to but not past the open bracket.
5330        Motion::UnmatchedBracket { .. } => RangeKind::Exclusive,
5331        // `$` now lands on the last char — operator ranges include it.
5332        Motion::LineEnd => RangeKind::Inclusive,
5333        // Linewise motions: +/-/_ land on the first non-blank of a line.
5334        Motion::FirstNonBlankNextLine
5335        | Motion::FirstNonBlankPrevLine
5336        | Motion::FirstNonBlankLine => RangeKind::Linewise,
5337        // [[/]]/[][/][ are charwise exclusive (land on the brace, brace excluded from operator).
5338        Motion::SectionBackward
5339        | Motion::SectionForward
5340        | Motion::SectionEndBackward
5341        | Motion::SectionEndForward => RangeKind::Exclusive,
5342        _ => RangeKind::Exclusive,
5343    }
5344}
5345
5346/// Linewise change of rows `[top_row, end_row]` (vim `cc`/`cj`/`Vc`/`cip`…).
5347///
5348/// Deletes the spanned lines, leaves one line carrying the first row's
5349/// leading whitespace (when `autoindent` is on), parks the cursor after
5350/// the indent, and enters insert mode. Records the full linewise payload
5351/// to the yank + delete registers and sets `change_mark_start` for the
5352/// `[`/`]` deferral. Calls `push_undo` internally — callers must NOT also
5353/// call it.
5354fn change_linewise_rows<H: crate::types::Host>(
5355    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5356    top_row: usize,
5357    end_row: usize,
5358) {
5359    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
5360    // Vim `:h '[`: stash change start for `]` deferral on insert-exit.
5361    ed.vim.change_mark_start = Some((top_row, 0));
5362    ed.push_undo();
5363    ed.sync_buffer_content_from_textarea();
5364    // Read the cut payload first so yank reflects every original line.
5365    let payload = read_vim_range(ed, (top_row, 0), (end_row, 0), RangeKind::Linewise);
5366    // Drop every row after the first (rows [top_row+1, end_row]).
5367    if end_row > top_row {
5368        ed.mutate_edit(Edit::DeleteRange {
5369            start: Position::new(top_row + 1, 0),
5370            end: Position::new(end_row, 0),
5371            kind: BufKind::Line,
5372        });
5373    }
5374    // Preserve the first row's leading whitespace when autoindent is on;
5375    // wipe the whole line content otherwise (cursor lands at col 0).
5376    let indent_chars = if ed.settings.autoindent {
5377        let line = hjkl_buffer::rope_line_str(&crate::types::Query::rope(&ed.buffer), top_row);
5378        line.chars().take_while(|c| *c == ' ' || *c == '\t').count()
5379    } else {
5380        0
5381    };
5382    let line_chars = buf_line_chars(&ed.buffer, top_row);
5383    if line_chars > indent_chars {
5384        ed.mutate_edit(Edit::DeleteRange {
5385            start: Position::new(top_row, indent_chars),
5386            end: Position::new(top_row, line_chars),
5387            kind: BufKind::Char,
5388        });
5389    }
5390    if !payload.is_empty() {
5391        ed.record_yank_to_host(payload.clone());
5392        ed.record_delete(payload, true);
5393    }
5394    buf_set_cursor_rc(&mut ed.buffer, top_row, indent_chars);
5395    ed.push_buffer_cursor_to_textarea();
5396    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
5397}
5398
5399fn run_operator_over_range<H: crate::types::Host>(
5400    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5401    op: Operator,
5402    start: (usize, usize),
5403    end: (usize, usize),
5404    kind: RangeKind,
5405) {
5406    let (top, bot) = order(start, end);
5407    // Charwise empty range (same position). For Delete/Yank there is nothing to
5408    // act on. For Change, vim still enters insert at that point — `ci(` on `()`
5409    // and `ci{` on a whitespace-only block both place the cursor inside and
5410    // start inserting without deleting anything.
5411    if top == bot && !matches!(kind, RangeKind::Linewise) {
5412        if op == Operator::Change {
5413            ed.vim.change_mark_start = Some(top);
5414            ed.push_undo();
5415            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
5416        }
5417        return;
5418    }
5419
5420    match op {
5421        Operator::Yank => {
5422            let text = read_vim_range(ed, top, bot, kind);
5423            if !text.is_empty() {
5424                ed.record_yank_to_host(text.clone());
5425                ed.record_yank(text, matches!(kind, RangeKind::Linewise));
5426            }
5427            // Vim `:h '[` / `:h ']`: after a yank `[` = first yanked char,
5428            // `]` = last yanked char. Mode-aware: linewise snaps to line
5429            // edges; charwise uses the actual inclusive endpoint.
5430            let rbr = match kind {
5431                RangeKind::Linewise => {
5432                    let last_col = buf_line_chars(&ed.buffer, bot.0).saturating_sub(1);
5433                    (bot.0, last_col)
5434                }
5435                RangeKind::Inclusive => (bot.0, bot.1),
5436                RangeKind::Exclusive => (bot.0, bot.1.saturating_sub(1)),
5437            };
5438            ed.set_mark('[', top);
5439            ed.set_mark(']', rbr);
5440            buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
5441            ed.push_buffer_cursor_to_textarea();
5442        }
5443        Operator::Delete => {
5444            ed.push_undo();
5445            cut_vim_range(ed, top, bot, kind);
5446            // After a charwise / inclusive delete the buffer cursor is
5447            // placed at `start` by the edit path. In Normal mode the
5448            // cursor max col is `line_len - 1`; clamp it here so e.g.
5449            // `d$` doesn't leave the cursor one past the new line end.
5450            if !matches!(kind, RangeKind::Linewise) {
5451                clamp_cursor_to_normal_mode(ed);
5452            }
5453            ed.vim.mode = Mode::Normal;
5454            // Vim `:h '[` / `:h ']`: after a delete both marks park at
5455            // the cursor position where the deletion collapsed (the join
5456            // point). Set after the cut and clamp so the position is final.
5457            let pos = ed.cursor();
5458            ed.set_mark('[', pos);
5459            ed.set_mark(']', pos);
5460        }
5461        Operator::Change => {
5462            // Vim `:h '[`: `[` is set to the start of the changed range
5463            // before the cut. `]` is deferred to insert-exit (AfterChange
5464            // path in finish_insert_session) where the cursor sits on the
5465            // last inserted char.
5466            if matches!(kind, RangeKind::Linewise) {
5467                // Linewise change (`cj`/`ck`/`cip`/`cap`/…): preserve the
5468                // first line's indent and leave exactly one row open for
5469                // insert. The helper handles push_undo + insert entry.
5470                change_linewise_rows(ed, top.0, bot.0);
5471            } else {
5472                // Charwise change: cut the range and enter insert.
5473                ed.vim.change_mark_start = Some(top);
5474                ed.push_undo();
5475                cut_vim_range(ed, top, bot, kind);
5476                begin_insert_noundo(ed, 1, InsertReason::AfterChange);
5477            }
5478        }
5479        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {
5480            apply_case_op_to_selection(ed, op, top, bot, kind);
5481        }
5482        Operator::Indent | Operator::Outdent => {
5483            // Indent / outdent are always linewise even when triggered
5484            // by a char-wise motion (e.g. `>w` indents the whole line).
5485            ed.push_undo();
5486            if op == Operator::Indent {
5487                indent_rows(ed, top.0, bot.0, 1);
5488            } else {
5489                outdent_rows(ed, top.0, bot.0, 1);
5490            }
5491            ed.vim.mode = Mode::Normal;
5492        }
5493        Operator::Fold => {
5494            // Always linewise — fold the spanned rows regardless of the
5495            // motion's natural kind. Cursor lands on `top.0` to mirror
5496            // the visual `zf` path.
5497            if bot.0 >= top.0 {
5498                ed.apply_fold_op(crate::types::FoldOp::Add {
5499                    start_row: top.0,
5500                    end_row: bot.0,
5501                    closed: true,
5502                });
5503            }
5504            buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
5505            ed.push_buffer_cursor_to_textarea();
5506            ed.vim.mode = Mode::Normal;
5507        }
5508        Operator::Reflow => {
5509            ed.push_undo();
5510            reflow_rows(ed, top.0, bot.0);
5511            ed.vim.mode = Mode::Normal;
5512        }
5513        Operator::ReflowKeepCursor => {
5514            // `gw{motion}` — reflow like `gq` but restore the cursor to the
5515            // character it was on before the reflow (vim's gw behaviour).
5516            let saved = ed.cursor();
5517            ed.push_undo();
5518            let (before, after) = reflow_rows_keep_cursor(ed, top.0, bot.0);
5519            let (new_row, new_col) = reflow_keep_cursor(top.0, saved.0, saved.1, &before, &after);
5520            buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
5521            ed.push_buffer_cursor_to_textarea();
5522            ed.sticky_col = Some(new_col);
5523            ed.vim.mode = Mode::Normal;
5524        }
5525        Operator::AutoIndent => {
5526            // Always linewise — like Indent/Outdent.
5527            ed.push_undo();
5528            auto_indent_rows(ed, top.0, bot.0);
5529            ed.vim.mode = Mode::Normal;
5530        }
5531        Operator::Filter => {
5532            // Filter is not dispatched through run_operator_over_range.
5533            // The app calls Editor::filter_range directly with a command string.
5534            // Reaching this arm means a caller invoked run_operator_over_range
5535            // with Operator::Filter by mistake — silently no-op.
5536        }
5537        Operator::Comment => {
5538            // Comment is dispatched through Editor::toggle_comment_range.
5539            // Reaching this arm is a caller mistake — silently no-op.
5540        }
5541    }
5542}
5543
5544// ─── Phase 4a pub range-mutation bridges ───────────────────────────────────
5545//
5546// These are `pub(crate)` entry points called by the five new pub methods on
5547// `Editor` (`delete_range`, `yank_range`, `change_range`, `indent_range`,
5548// `case_range`). They set `pending_register` from the caller-supplied char
5549// before delegating to the existing internal helpers so register semantics
5550// (unnamed `"`, named `"a`–`"z`, delete ring) are honoured exactly as in the
5551// FSM path.
5552//
5553// Do NOT call `run_operator_over_range` for Indent/Outdent or the three case
5554// operators — those share the FSM path but have dedicated parameter shapes
5555// (signed count, Operator-as-CaseOp) that map more cleanly to their own
5556// helpers.
5557
5558/// Delete the range `[start, end)` (interpretation determined by `kind`) and
5559/// stash the deleted text in `register`. `'"'` is the unnamed register.
5560pub(crate) fn delete_range_bridge<H: crate::types::Host>(
5561    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5562    start: (usize, usize),
5563    end: (usize, usize),
5564    kind: RangeKind,
5565    register: char,
5566) {
5567    ed.vim.pending_register = Some(register);
5568    run_operator_over_range(ed, Operator::Delete, start, end, kind);
5569}
5570
5571/// Yank (copy) the range `[start, end)` into `register` without mutating the
5572/// buffer. `'"'` is the unnamed register.
5573pub(crate) fn yank_range_bridge<H: crate::types::Host>(
5574    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5575    start: (usize, usize),
5576    end: (usize, usize),
5577    kind: RangeKind,
5578    register: char,
5579) {
5580    ed.vim.pending_register = Some(register);
5581    run_operator_over_range(ed, Operator::Yank, start, end, kind);
5582}
5583
5584/// Delete the range `[start, end)` and enter Insert mode (vim `c` operator).
5585/// The deleted text is stashed in `register`. Mode transitions to Insert on
5586/// return; the caller must not issue further normal-mode ops until the insert
5587/// session ends.
5588pub(crate) fn change_range_bridge<H: crate::types::Host>(
5589    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5590    start: (usize, usize),
5591    end: (usize, usize),
5592    kind: RangeKind,
5593    register: char,
5594) {
5595    ed.vim.pending_register = Some(register);
5596    run_operator_over_range(ed, Operator::Change, start, end, kind);
5597}
5598
5599/// Indent (`count > 0`) or outdent (`count < 0`) the row span `[start.0,
5600/// end.0]`. `shiftwidth` overrides the editor's `settings().shiftwidth` for
5601/// this call; pass `0` to use the editor setting. The column parts of `start`
5602/// / `end` are ignored — indent is always linewise.
5603pub(crate) fn indent_range_bridge<H: crate::types::Host>(
5604    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5605    start: (usize, usize),
5606    end: (usize, usize),
5607    count: i32,
5608    shiftwidth: u32,
5609) {
5610    if count == 0 {
5611        return;
5612    }
5613    let (top_row, bot_row) = if start.0 <= end.0 {
5614        (start.0, end.0)
5615    } else {
5616        (end.0, start.0)
5617    };
5618    // Temporarily override shiftwidth when the caller provides one.
5619    let original_sw = ed.settings().shiftwidth;
5620    if shiftwidth > 0 {
5621        ed.settings_mut().shiftwidth = shiftwidth as usize;
5622    }
5623    ed.push_undo();
5624    let abs_count = count.unsigned_abs() as usize;
5625    if count > 0 {
5626        indent_rows(ed, top_row, bot_row, abs_count);
5627    } else {
5628        outdent_rows(ed, top_row, bot_row, abs_count);
5629    }
5630    if shiftwidth > 0 {
5631        ed.settings_mut().shiftwidth = original_sw;
5632    }
5633    ed.vim.mode = Mode::Normal;
5634}
5635
5636/// Apply a case transformation (`Uppercase` / `Lowercase` / `ToggleCase`) to
5637/// the range `[start, end)`. Only the three case `Operator` variants are valid;
5638/// other variants are silently ignored (no-op).
5639pub(crate) fn case_range_bridge<H: crate::types::Host>(
5640    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5641    start: (usize, usize),
5642    end: (usize, usize),
5643    kind: RangeKind,
5644    op: Operator,
5645) {
5646    match op {
5647        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {}
5648        _ => return,
5649    }
5650    let (top, bot) = order(start, end);
5651    apply_case_op_to_selection(ed, op, top, bot, kind);
5652}
5653
5654// ─── Phase 4e pub block-shape range-mutation bridges ───────────────────────
5655//
5656// These are `pub(crate)` entry points called by the four new pub methods on
5657// `Editor` (`delete_block`, `yank_block`, `change_block`, `indent_block`).
5658// They set `pending_register` from the caller-supplied char then delegate to
5659// `apply_block_operator` (after temporarily installing the 4-corner block as
5660// the engine's virtual VisualBlock selection). The editor's VisualBlock state
5661// fields (`block_anchor`, `block_vcol`) are overwritten, the op fires, then
5662// the fields are restored to their pre-call values. This ensures the engine's
5663// register / undo / mode semantics are exercised without requiring the caller
5664// to already be in VisualBlock mode.
5665//
5666// `indent_block` is a separate helper — it does not use `apply_block_operator`
5667// because indent/outdent are always linewise for blocks (vim behaviour).
5668
5669/// Delete a rectangular VisualBlock selection. `top_row`/`bot_row` are
5670/// inclusive line bounds; `left_col`/`right_col` are inclusive char-column
5671/// bounds. Short lines that don't reach `right_col` lose only the chars
5672/// that exist (ragged-edge, matching engine FSM). `register` is honoured;
5673/// `'"'` selects the unnamed register.
5674pub(crate) fn delete_block_bridge<H: crate::types::Host>(
5675    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5676    top_row: usize,
5677    bot_row: usize,
5678    left_col: usize,
5679    right_col: usize,
5680    register: char,
5681) {
5682    ed.vim.pending_register = Some(register);
5683    let saved_anchor = ed.vim.block_anchor;
5684    let saved_vcol = ed.vim.block_vcol;
5685    ed.vim.block_anchor = (top_row, left_col);
5686    ed.vim.block_vcol = right_col;
5687    // Compute clamped col before the mutable borrow for buf_set_cursor_rc.
5688    let clamped = right_col.min(buf_line_chars(&ed.buffer, bot_row).saturating_sub(1));
5689    // Place cursor at bot_row / right_col so block_bounds resolves correctly.
5690    buf_set_cursor_rc(&mut ed.buffer, bot_row, clamped);
5691    apply_block_operator(ed, Operator::Delete, 1);
5692    // Restore — block_anchor/vcol are only meaningful in VisualBlock mode;
5693    // after the op we're in Normal so restoring is a no-op for the user but
5694    // keeps state coherent if the caller inspects fields.
5695    ed.vim.block_anchor = saved_anchor;
5696    ed.vim.block_vcol = saved_vcol;
5697}
5698
5699/// Yank a rectangular VisualBlock selection into `register`.
5700pub(crate) fn yank_block_bridge<H: crate::types::Host>(
5701    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5702    top_row: usize,
5703    bot_row: usize,
5704    left_col: usize,
5705    right_col: usize,
5706    register: char,
5707) {
5708    ed.vim.pending_register = Some(register);
5709    let saved_anchor = ed.vim.block_anchor;
5710    let saved_vcol = ed.vim.block_vcol;
5711    ed.vim.block_anchor = (top_row, left_col);
5712    ed.vim.block_vcol = right_col;
5713    let clamped = right_col.min(buf_line_chars(&ed.buffer, bot_row).saturating_sub(1));
5714    buf_set_cursor_rc(&mut ed.buffer, bot_row, clamped);
5715    apply_block_operator(ed, Operator::Yank, 1);
5716    ed.vim.block_anchor = saved_anchor;
5717    ed.vim.block_vcol = saved_vcol;
5718}
5719
5720/// Delete a rectangular VisualBlock selection and enter Insert mode (`c`).
5721/// The deleted text is stashed in `register`. Mode is Insert on return.
5722pub(crate) fn change_block_bridge<H: crate::types::Host>(
5723    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5724    top_row: usize,
5725    bot_row: usize,
5726    left_col: usize,
5727    right_col: usize,
5728    register: char,
5729) {
5730    ed.vim.pending_register = Some(register);
5731    let saved_anchor = ed.vim.block_anchor;
5732    let saved_vcol = ed.vim.block_vcol;
5733    ed.vim.block_anchor = (top_row, left_col);
5734    ed.vim.block_vcol = right_col;
5735    let clamped = right_col.min(buf_line_chars(&ed.buffer, bot_row).saturating_sub(1));
5736    buf_set_cursor_rc(&mut ed.buffer, bot_row, clamped);
5737    apply_block_operator(ed, Operator::Change, 1);
5738    ed.vim.block_anchor = saved_anchor;
5739    ed.vim.block_vcol = saved_vcol;
5740}
5741
5742/// Indent (`count > 0`) or outdent (`count < 0`) rows `top_row..=bot_row`.
5743/// Column bounds are ignored — vim's block indent is always linewise.
5744/// `count == 0` is a no-op.
5745pub(crate) fn indent_block_bridge<H: crate::types::Host>(
5746    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5747    top_row: usize,
5748    bot_row: usize,
5749    count: i32,
5750) {
5751    if count == 0 {
5752        return;
5753    }
5754    ed.push_undo();
5755    let abs = count.unsigned_abs() as usize;
5756    if count > 0 {
5757        indent_rows(ed, top_row, bot_row, abs);
5758    } else {
5759        outdent_rows(ed, top_row, bot_row, abs);
5760    }
5761    ed.vim.mode = Mode::Normal;
5762}
5763
5764/// Auto-indent (v1 dumb shiftwidth) the row span `[start.0, end.0]`. Column
5765/// parts are ignored — auto-indent is always linewise. See
5766/// `auto_indent_rows` for the algorithm and its v1 limitations.
5767pub(crate) fn auto_indent_range_bridge<H: crate::types::Host>(
5768    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5769    start: (usize, usize),
5770    end: (usize, usize),
5771) {
5772    let (top_row, bot_row) = if start.0 <= end.0 {
5773        (start.0, end.0)
5774    } else {
5775        (end.0, start.0)
5776    };
5777    ed.push_undo();
5778    auto_indent_rows(ed, top_row, bot_row);
5779    ed.vim.mode = Mode::Normal;
5780}
5781
5782// ─── Phase 4b pub text-object resolution bridges ───────────────────────────
5783//
5784// These are `pub(crate)` entry points called by the four new pub methods on
5785// `Editor` (`text_object_inner_word`, `text_object_around_word`,
5786// `text_object_inner_big_word`, `text_object_around_big_word`). They delegate
5787// to `word_text_object` — the existing private resolver — without touching any
5788// operator, register, or mode state. Pure functions: only `&Editor` required.
5789
5790/// Resolve the range of `iw` (inner word) at the current cursor position.
5791/// Returns `None` if no word exists at the cursor.
5792pub(crate) fn text_object_inner_word_bridge<H: crate::types::Host>(
5793    ed: &Editor<hjkl_buffer::Buffer, H>,
5794) -> Option<((usize, usize), (usize, usize))> {
5795    word_text_object(ed, true, false)
5796}
5797
5798/// Resolve the range of `aw` (around word) at the current cursor position.
5799/// Includes trailing whitespace (or leading whitespace if no trailing exists).
5800pub(crate) fn text_object_around_word_bridge<H: crate::types::Host>(
5801    ed: &Editor<hjkl_buffer::Buffer, H>,
5802) -> Option<((usize, usize), (usize, usize))> {
5803    word_text_object(ed, false, false)
5804}
5805
5806/// Resolve the range of `iW` (inner WORD) at the current cursor position.
5807/// A WORD is any run of non-whitespace characters (no punctuation splitting).
5808pub(crate) fn text_object_inner_big_word_bridge<H: crate::types::Host>(
5809    ed: &Editor<hjkl_buffer::Buffer, H>,
5810) -> Option<((usize, usize), (usize, usize))> {
5811    word_text_object(ed, true, true)
5812}
5813
5814/// Resolve the range of `aW` (around WORD) at the current cursor position.
5815/// Includes trailing whitespace (or leading whitespace if no trailing exists).
5816pub(crate) fn text_object_around_big_word_bridge<H: crate::types::Host>(
5817    ed: &Editor<hjkl_buffer::Buffer, H>,
5818) -> Option<((usize, usize), (usize, usize))> {
5819    word_text_object(ed, false, true)
5820}
5821
5822// ─── Phase 4c pub text-object resolution bridges (quote + bracket) ──────────
5823//
5824// `pub(crate)` entry points called by the four new pub methods on `Editor`
5825// (`text_object_inner_quote`, `text_object_around_quote`,
5826// `text_object_inner_bracket`, `text_object_around_bracket`). They delegate to
5827// `quote_text_object` / `bracket_text_object` — the existing private resolvers
5828// — without touching any operator, register, or mode state.
5829//
5830// `bracket_text_object` returns `Option<(Pos, Pos, RangeKind)>`; the bridges
5831// strip the `RangeKind` tag so callers see a uniform
5832// `Option<((usize,usize),(usize,usize))>` shape, consistent with 4b.
5833
5834/// Resolve the range of `i<quote>` (inner quote) at the current cursor
5835/// position. `quote` is one of `'"'`, `'\''`, or `` '`' ``. Returns `None`
5836/// when the cursor's line contains fewer than two occurrences of `quote`.
5837pub(crate) fn text_object_inner_quote_bridge<H: crate::types::Host>(
5838    ed: &Editor<hjkl_buffer::Buffer, H>,
5839    quote: char,
5840) -> Option<((usize, usize), (usize, usize))> {
5841    quote_text_object(ed, quote, true)
5842}
5843
5844/// Resolve the range of `a<quote>` (around quote) at the current cursor
5845/// position. Includes surrounding whitespace on one side per vim semantics.
5846pub(crate) fn text_object_around_quote_bridge<H: crate::types::Host>(
5847    ed: &Editor<hjkl_buffer::Buffer, H>,
5848    quote: char,
5849) -> Option<((usize, usize), (usize, usize))> {
5850    quote_text_object(ed, quote, false)
5851}
5852
5853/// Resolve the range of `i<bracket>` (inner bracket pair). `open` must be
5854/// one of `'('`, `'{'`, `'['`, `'<'`; the corresponding close is derived
5855/// internally. Returns `None` when no enclosing pair is found. The returned
5856/// range excludes the bracket characters themselves. Multi-line bracket pairs
5857/// whose content spans more than one line are reported as a charwise range
5858/// covering the first content character through the last content character
5859/// (RangeKind metadata is stripped — callers receive start/end only).
5860pub(crate) fn text_object_inner_bracket_bridge<H: crate::types::Host>(
5861    ed: &Editor<hjkl_buffer::Buffer, H>,
5862    open: char,
5863) -> Option<((usize, usize), (usize, usize))> {
5864    bracket_text_object(ed, open, true, 1).map(|(s, e, _kind)| (s, e))
5865}
5866
5867/// Resolve the range of `a<bracket>` (around bracket pair). Includes the
5868/// bracket characters themselves. `open` must be one of `'('`, `'{'`, `'['`,
5869/// `'<'`.
5870pub(crate) fn text_object_around_bracket_bridge<H: crate::types::Host>(
5871    ed: &Editor<hjkl_buffer::Buffer, H>,
5872    open: char,
5873) -> Option<((usize, usize), (usize, usize))> {
5874    bracket_text_object(ed, open, false, 1).map(|(s, e, _kind)| (s, e))
5875}
5876
5877// ── Sentence bridges (is / as) ─────────────────────────────────────────────
5878
5879/// Resolve the range of `is` (inner sentence) at the cursor. Excludes
5880/// trailing whitespace.
5881pub(crate) fn text_object_inner_sentence_bridge<H: crate::types::Host>(
5882    ed: &Editor<hjkl_buffer::Buffer, H>,
5883) -> Option<((usize, usize), (usize, usize))> {
5884    sentence_text_object(ed, true)
5885}
5886
5887/// Resolve the range of `as` (around sentence) at the cursor. Includes
5888/// trailing whitespace.
5889pub(crate) fn text_object_around_sentence_bridge<H: crate::types::Host>(
5890    ed: &Editor<hjkl_buffer::Buffer, H>,
5891) -> Option<((usize, usize), (usize, usize))> {
5892    sentence_text_object(ed, false)
5893}
5894
5895// ── Paragraph bridges (ip / ap) ────────────────────────────────────────────
5896
5897/// Resolve the range of `ip` (inner paragraph) at the cursor. A paragraph
5898/// is a block of non-blank lines bounded by blank lines or buffer edges.
5899pub(crate) fn text_object_inner_paragraph_bridge<H: crate::types::Host>(
5900    ed: &Editor<hjkl_buffer::Buffer, H>,
5901) -> Option<((usize, usize), (usize, usize))> {
5902    paragraph_text_object(ed, true)
5903}
5904
5905/// Resolve the range of `ap` (around paragraph) at the cursor. Includes one
5906/// trailing blank line when present.
5907pub(crate) fn text_object_around_paragraph_bridge<H: crate::types::Host>(
5908    ed: &Editor<hjkl_buffer::Buffer, H>,
5909) -> Option<((usize, usize), (usize, usize))> {
5910    paragraph_text_object(ed, false)
5911}
5912
5913// ── Tag bridges (it / at) ──────────────────────────────────────────────────
5914
5915/// Resolve the range of `it` (inner tag) at the cursor. Matches XML/HTML-style
5916/// `<tag>...</tag>` pairs; returns the range of inner content between the open
5917/// and close tags.
5918pub(crate) fn text_object_inner_tag_bridge<H: crate::types::Host>(
5919    ed: &Editor<hjkl_buffer::Buffer, H>,
5920) -> Option<((usize, usize), (usize, usize))> {
5921    tag_text_object(ed, true)
5922}
5923
5924/// Resolve the range of `at` (around tag) at the cursor. Includes the open
5925/// and close tag delimiters themselves.
5926pub(crate) fn text_object_around_tag_bridge<H: crate::types::Host>(
5927    ed: &Editor<hjkl_buffer::Buffer, H>,
5928) -> Option<((usize, usize), (usize, usize))> {
5929    tag_text_object(ed, false)
5930}
5931
5932// ─── Rope utility helpers ──────────────────────────────────────────────────
5933
5934/// Return row `r` from a rope as an owned `String`, stripping the
5935/// trailing `\n` that ropey includes on non-final lines.
5936pub(crate) fn rope_line_to_str(rope: &ropey::Rope, r: usize) -> String {
5937    let s = rope.line(r).to_string();
5938    // ropey includes the newline; strip it so callers see bare content.
5939    if s.ends_with('\n') {
5940        s[..s.len() - 1].to_string()
5941    } else {
5942        s
5943    }
5944}
5945
5946/// Join rows `lo..=hi` from a rope into a single `String` separated by
5947/// `\n`. Callers must ensure `lo <= hi < rope.len_lines()`.
5948pub(crate) fn rope_row_range_str(rope: &ropey::Rope, lo: usize, hi: usize) -> String {
5949    let n = rope.len_lines();
5950    let lo = lo.min(n.saturating_sub(1));
5951    let hi = hi.min(n.saturating_sub(1));
5952    if lo > hi {
5953        return String::new();
5954    }
5955    // Use byte-slice to grab the full range in one rope walk.
5956    let start_byte = rope.line_to_byte(lo);
5957    // End byte: start of line hi+1, minus the newline separator, or
5958    // len_bytes() when hi is the last line.
5959    let end_byte = if hi + 1 < n {
5960        // line_to_byte(hi+1) points at the \n-terminated start of
5961        // the next line; step back one byte to drop that trailing \n.
5962        rope.line_to_byte(hi + 1).saturating_sub(1)
5963    } else {
5964        rope.len_bytes()
5965    };
5966    rope.byte_slice(start_byte..end_byte).to_string()
5967}
5968
5969/// Snapshot all rows from a rope as `Vec<String>` (no trailing `\n`).
5970/// Use only when the caller truly needs mutable per-row access; prefer
5971/// rope iterators otherwise.
5972pub(crate) fn rope_to_lines_vec(rope: &ropey::Rope) -> Vec<String> {
5973    let n = rope.len_lines();
5974    (0..n).map(|r| rope_line_to_str(rope, r)).collect()
5975}
5976
5977/// Pure greedy word-wrap of a slice of lines to `width` chars.
5978/// Returns `(original_slice, wrapped_lines)`.
5979/// Blank lines are preserved as paragraph separators.
5980fn greedy_wrap(original: &[String], width: usize) -> Vec<String> {
5981    let mut wrapped: Vec<String> = Vec::new();
5982    let mut paragraph: Vec<String> = Vec::new();
5983    let flush = |para: &mut Vec<String>, out: &mut Vec<String>, width: usize| {
5984        if para.is_empty() {
5985            return;
5986        }
5987        let words = para.join(" ");
5988        let mut current = String::new();
5989        for word in words.split_whitespace() {
5990            let extra = if current.is_empty() {
5991                word.chars().count()
5992            } else {
5993                current.chars().count() + 1 + word.chars().count()
5994            };
5995            if extra > width && !current.is_empty() {
5996                out.push(std::mem::take(&mut current));
5997                current.push_str(word);
5998            } else if current.is_empty() {
5999                current.push_str(word);
6000            } else {
6001                current.push(' ');
6002                current.push_str(word);
6003            }
6004        }
6005        if !current.is_empty() {
6006            out.push(current);
6007        }
6008        para.clear();
6009    };
6010    for line in original {
6011        if line.trim().is_empty() {
6012            flush(&mut paragraph, &mut wrapped, width);
6013            wrapped.push(String::new());
6014        } else {
6015            paragraph.push(line.clone());
6016        }
6017    }
6018    flush(&mut paragraph, &mut wrapped, width);
6019    wrapped
6020}
6021
6022/// Greedy word-wrap the rows in `[top, bot]` to `settings.textwidth`.
6023/// Splits on blank-line boundaries so paragraph structure is
6024/// preserved. Each paragraph's words are joined with single spaces
6025/// before re-wrapping. Cursor lands at `(top, 0)` after the call
6026/// (via `ed.restore`).
6027fn reflow_rows<H: crate::types::Host>(
6028    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6029    top: usize,
6030    bot: usize,
6031) {
6032    let width = ed.settings().textwidth.max(1);
6033    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
6034    let bot = bot.min(lines.len().saturating_sub(1));
6035    if top > bot {
6036        return;
6037    }
6038    let original = lines[top..=bot].to_vec();
6039    let wrapped = greedy_wrap(&original, width);
6040
6041    // vim leaves the cursor on the last NON-BLANK line of the reflowed range
6042    // (a trailing blank from `ap` etc. is not counted).
6043    let last_offset = wrapped
6044        .iter()
6045        .rposition(|l| !l.trim().is_empty())
6046        .unwrap_or(0);
6047    let last_row = top + last_offset;
6048
6049    // Splice back. push_undo above means `u` reverses.
6050    let after: Vec<String> = lines.split_off(bot + 1);
6051    lines.truncate(top);
6052    lines.extend(wrapped);
6053    lines.extend(after);
6054    ed.restore(lines, (last_row, 0));
6055    move_first_non_whitespace(ed);
6056    ed.mark_content_dirty();
6057}
6058
6059/// Same reflow as `reflow_rows` but also returns the pre-reflow slice
6060/// and the wrapped lines so the caller can compute a character-preserving
6061/// cursor position via [`reflow_keep_cursor`].
6062fn reflow_rows_keep_cursor<H: crate::types::Host>(
6063    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6064    top: usize,
6065    bot: usize,
6066) -> (Vec<String>, Vec<String>) {
6067    let width = ed.settings().textwidth.max(1);
6068    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
6069    let bot = bot.min(lines.len().saturating_sub(1));
6070    if top > bot {
6071        return (Vec::new(), Vec::new());
6072    }
6073    let original = lines[top..=bot].to_vec();
6074    let wrapped = greedy_wrap(&original, width);
6075
6076    let after: Vec<String> = lines.split_off(bot + 1);
6077    lines.truncate(top);
6078    lines.extend(wrapped.clone());
6079    lines.extend(after);
6080    ed.restore(lines, (top, 0));
6081    ed.mark_content_dirty();
6082    (original, wrapped)
6083}
6084
6085/// Compute the new `(row, col)` that preserves the character the cursor
6086/// was on after `reflow_rows` has been applied to `[top, bot]`.
6087///
6088/// Algorithm (mirrors nvim's `gw` behaviour):
6089/// 1. Count the char-index of `(cursor_row, cursor_col)` relative to the
6090///    start of line `top` in `before_lines` (the pre-reflow snapshot).
6091/// 2. Walk the `after_lines` (the wrapped output) to find the row/col
6092///    that has the same char index.
6093///
6094/// If the cursor was past the end of the reflowed content (e.g. beyond
6095/// the last char), we clamp to the last char of the last reflowed line.
6096fn reflow_keep_cursor(
6097    top: usize,
6098    cursor_row: usize,
6099    cursor_col: usize,
6100    before_lines: &[String],
6101    after_lines: &[String],
6102) -> (usize, usize) {
6103    // Char offset of cursor within the before_lines range.
6104    // Each line contributes its chars; lines are separated by a single
6105    // space in the collapsed paragraph — but since reflow joins everything
6106    // and re-wraps with spaces, counting by chars-per-line (plus the
6107    // conceptual space separator between lines) mirrors the join.
6108    //
6109    // The simpler approach (which nvim appears to use): the cursor offset
6110    // within the range is the sum of chars in lines before cursor_row
6111    // (each + 1 for the space/newline separator) plus cursor_col, then
6112    // find that position in the wrapped text.
6113    //
6114    // Actually, since reflow collapses whitespace (split_whitespace),
6115    // the simplest approach is to track the cursor's char in the ORIGINAL
6116    // concatenated text and find it in the reflowed text.
6117
6118    // Build the original range text as it appears when joined for wrapping:
6119    // same as what reflow does internally — join with spaces.
6120    // But we want raw character index, so we accumulate char counts per line
6121    // (without the trailing newline).
6122    let relative_row = cursor_row.saturating_sub(top);
6123    let mut char_offset: usize = 0;
6124    for (i, line) in before_lines.iter().enumerate() {
6125        if i == relative_row {
6126            // Add clamped col within this line.
6127            let line_len = line.chars().count();
6128            char_offset += cursor_col.min(line_len);
6129            break;
6130        }
6131        // Each line contributes its chars plus a newline (or space boundary).
6132        char_offset += line.chars().count() + 1;
6133    }
6134
6135    // Now find char_offset in after_lines.
6136    let mut remaining = char_offset;
6137    for (i, line) in after_lines.iter().enumerate() {
6138        let len = line.chars().count();
6139        if remaining <= len {
6140            // The col is clamped to line_len - 1 in Normal mode.
6141            let col = remaining.min(if len == 0 { 0 } else { len.saturating_sub(1) });
6142            return (top + i, col);
6143        }
6144        // Not on this line; subtract line len + 1 (newline separator).
6145        remaining = remaining.saturating_sub(len + 1);
6146    }
6147
6148    // Cursor was beyond the end of the reflowed content — clamp to last line.
6149    let last = after_lines.len().saturating_sub(1);
6150    let last_len = after_lines
6151        .get(last)
6152        .map(|l| l.chars().count())
6153        .unwrap_or(0);
6154    let col = if last_len == 0 { 0 } else { last_len - 1 };
6155    (top + last, col)
6156}
6157
6158/// Transform the range `[top, bot]` (vim `RangeKind`) in place with
6159/// the given case operator. Cursor lands on `top` afterward — vim
6160/// convention for `gU{motion}` / `gu{motion}` / `g~{motion}`.
6161/// Preserves the textarea yank buffer (vim's case operators don't
6162/// touch registers).
6163fn apply_case_op_to_selection<H: crate::types::Host>(
6164    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6165    op: Operator,
6166    top: (usize, usize),
6167    bot: (usize, usize),
6168    kind: RangeKind,
6169) {
6170    use hjkl_buffer::Edit;
6171    ed.push_undo();
6172    let saved_yank = ed.yank().to_string();
6173    let saved_yank_linewise = ed.vim.yank_linewise;
6174    let selection = cut_vim_range(ed, top, bot, kind);
6175    let transformed = match op {
6176        Operator::Uppercase => selection.to_uppercase(),
6177        Operator::Lowercase => selection.to_lowercase(),
6178        Operator::ToggleCase => toggle_case_str(&selection),
6179        Operator::Rot13 => rot13_str(&selection),
6180        _ => unreachable!(),
6181    };
6182    if !transformed.is_empty() {
6183        let cursor = buf_cursor_pos(&ed.buffer);
6184        ed.mutate_edit(Edit::InsertStr {
6185            at: cursor,
6186            text: transformed,
6187        });
6188    }
6189    buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
6190    ed.push_buffer_cursor_to_textarea();
6191    ed.set_yank(saved_yank);
6192    ed.vim.yank_linewise = saved_yank_linewise;
6193    ed.vim.mode = Mode::Normal;
6194}
6195
6196/// Prepend `count * shiftwidth` spaces to each row in `[top, bot]`.
6197/// Rows that are empty are skipped (vim leaves blank lines alone when
6198/// indenting). `shiftwidth` is read from `editor.settings()` so
6199/// `:set shiftwidth=N` takes effect on the next operation.
6200fn indent_rows<H: crate::types::Host>(
6201    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6202    top: usize,
6203    bot: usize,
6204    count: usize,
6205) {
6206    ed.sync_buffer_content_from_textarea();
6207    let width = ed.settings().shiftwidth * count.max(1);
6208    let pad: String = " ".repeat(width);
6209    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
6210    let bot = bot.min(lines.len().saturating_sub(1));
6211    for line in lines.iter_mut().take(bot + 1).skip(top) {
6212        if !line.is_empty() {
6213            line.insert_str(0, &pad);
6214        }
6215    }
6216    // Restore cursor to first non-blank of the top row so the next
6217    // vertical motion aims sensibly — matches vim's `>>` convention.
6218    ed.restore(lines, (top, 0));
6219    move_first_non_whitespace(ed);
6220}
6221
6222/// Remove up to `count * shiftwidth` leading spaces (or tabs) from
6223/// each row in `[top, bot]`. Rows with less leading whitespace have
6224/// all their indent stripped, not clipped to zero length.
6225fn outdent_rows<H: crate::types::Host>(
6226    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6227    top: usize,
6228    bot: usize,
6229    count: usize,
6230) {
6231    ed.sync_buffer_content_from_textarea();
6232    let width = ed.settings().shiftwidth * count.max(1);
6233    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
6234    let bot = bot.min(lines.len().saturating_sub(1));
6235    for line in lines.iter_mut().take(bot + 1).skip(top) {
6236        let strip: usize = line
6237            .chars()
6238            .take(width)
6239            .take_while(|c| *c == ' ' || *c == '\t')
6240            .count();
6241        if strip > 0 {
6242            let byte_len: usize = line.chars().take(strip).map(|c| c.len_utf8()).sum();
6243            line.drain(..byte_len);
6244        }
6245    }
6246    ed.restore(lines, (top, 0));
6247    move_first_non_whitespace(ed);
6248}
6249
6250/// Count the number of open/close bracket pairs on a single line for the
6251/// auto-indent depth scanner. Only bare bracket scanning — does NOT handle
6252/// string literals or comments (v1 limitation, documented on
6253/// `auto_indent_range_bridge`).
6254/// Net bracket count `(open - close)` for a single line, skipping
6255/// brackets inside `//` line comments, `"..."` string literals, and
6256/// `'X'` char literals.
6257///
6258/// String / char escapes (`\"`, `\'`, `\\`) are honored so the closing
6259/// quote isn't missed when the literal contains a backslash.
6260///
6261/// Limitations:
6262/// - Block comments `/* ... */` are NOT tracked across lines (a single
6263///   line `/* foo { bar } */` is correctly skipped only because the
6264///   `/*` and `*/` are on the same line and we'd see `{` after `/*`).
6265///   For v1 we leave this since block comments mid-code are rare.
6266/// - Raw string literals `r"..."` / `r#"..."#` are NOT special-cased.
6267/// - Lifetime annotations like `'a` look like an unterminated char
6268///   literal — handled by the heuristic that a char literal MUST close
6269///   within the line; if the closing `'` isn't found, treat the `'` as
6270///   a normal character (lifetime).
6271///
6272/// Pre-fix the scan was naive — `//! ... }` on a doc comment
6273/// decremented depth, cascading wrong indentation through the rest of
6274/// the file. This caused ~19% of lines to mis-indent on a real Rust
6275/// source diagnostic.
6276fn bracket_net(line: &str) -> i32 {
6277    let mut net: i32 = 0;
6278    let mut chars = line.chars().peekable();
6279    while let Some(ch) = chars.next() {
6280        match ch {
6281            // `//` → rest of line is a comment, stop.
6282            '/' if chars.peek() == Some(&'/') => return net,
6283            '"' => {
6284                // String literal — consume until unescaped closing `"`.
6285                while let Some(c) = chars.next() {
6286                    match c {
6287                        '\\' => {
6288                            chars.next();
6289                        } // skip escape byte
6290                        '"' => break,
6291                        _ => {}
6292                    }
6293                }
6294            }
6295            '\'' => {
6296                // Char literal OR lifetime. A char literal closes within
6297                // a few chars (one or two for escapes). A lifetime is
6298                // `'ident` with no closing quote.
6299                //
6300                // Strategy: peek ahead for a closing `'`. If found
6301                // within ~4 chars, consume as char literal. Otherwise
6302                // treat the `'` as the start of a lifetime — leave the
6303                // remaining chars to be scanned normally.
6304                let saved: Vec<char> = chars.clone().take(5).collect();
6305                let close_idx = if saved.first() == Some(&'\\') {
6306                    saved.iter().skip(2).position(|&c| c == '\'').map(|p| p + 2)
6307                } else {
6308                    saved.iter().skip(1).position(|&c| c == '\'').map(|p| p + 1)
6309                };
6310                if let Some(idx) = close_idx {
6311                    for _ in 0..=idx {
6312                        chars.next();
6313                    }
6314                }
6315                // If no close found, leave chars alone — lifetime path.
6316            }
6317            '{' | '(' | '[' => net += 1,
6318            '}' | ')' | ']' => net -= 1,
6319            _ => {}
6320        }
6321    }
6322    net
6323}
6324
6325/// Reindent rows `[top, bot]` using shiftwidth-based bracket-depth counting.
6326///
6327/// The indent for each line is computed as follows:
6328/// 1. Scan all rows from 0 up to the target row, accumulating a bracket depth
6329///    (`depth`) from net open − close brackets per line. The scan starts at row
6330///    0 to give correct depth for code that appears mid-buffer.
6331/// 2. For the target line, peek at its first non-whitespace character:
6332///    if it is a close bracket (`}`, `)`, `]`) then `effective_depth =
6333///    depth.saturating_sub(1)`; otherwise `effective_depth = depth`.
6334/// 3. Strip the line's existing leading whitespace and prepend
6335///    `effective_depth × indent_unit` where `indent_unit` is `"\t"` when
6336///    `expandtab == false` or `" " × shiftwidth` when `expandtab == true`.
6337/// 4. Empty / whitespace-only lines are left empty (no trailing whitespace).
6338/// 5. After computing the new line, advance `depth` by the line's bracket
6339///    net count (open − close), where the leading close-bracket already
6340///    contributed `−1` to the net of its own line.
6341///
6342/// **v1 limitation**: the bracket scan is naive — it does not skip brackets
6343/// inside string literals (`"{"`, `'['`) or comments (`// {`). Code with
6344/// such patterns will produce incorrect indent depths. Tree-sitter / LSP
6345/// indentation is deferred to a follow-up.
6346fn auto_indent_rows<H: crate::types::Host>(
6347    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6348    top: usize,
6349    bot: usize,
6350) {
6351    ed.sync_buffer_content_from_textarea();
6352    let shiftwidth = ed.settings().shiftwidth;
6353    let expandtab = ed.settings().expandtab;
6354    let indent_unit: String = if expandtab {
6355        " ".repeat(shiftwidth)
6356    } else {
6357        "\t".to_string()
6358    };
6359
6360    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
6361    let bot = bot.min(lines.len().saturating_sub(1));
6362
6363    // Accumulate bracket depth from row 0 up to `top - 1` so we start with
6364    // the correct depth for the first line of the target range.
6365    let mut depth: i32 = 0;
6366    for line in lines.iter().take(top) {
6367        depth += bracket_net(line);
6368        if depth < 0 {
6369            depth = 0;
6370        }
6371    }
6372
6373    for line in lines.iter_mut().take(bot + 1).skip(top) {
6374        let trimmed_owned = line.trim_start().to_owned();
6375        // Empty / whitespace-only lines stay empty.
6376        if trimmed_owned.is_empty() {
6377            *line = String::new();
6378            // depth contribution from an empty line is zero; no bracket scan needed.
6379            continue;
6380        }
6381
6382        // Detect leading close-bracket for effective depth.
6383        let starts_with_close = trimmed_owned
6384            .chars()
6385            .next()
6386            .is_some_and(|c| matches!(c, '}' | ')' | ']'));
6387        // Chain continuation: a line starting with `.` (e.g. `.foo()`)
6388        // hangs off the previous expression and gets one extra indent
6389        // level, matching cargo fmt / clang-format conventions for
6390        // method chains like:
6391        //   let x = foo()
6392        //       .bar()
6393        //       .baz();
6394        // Range expressions (`..`) and try-chains (`?.`) are out of
6395        // scope for v1 — single leading `.` is the common case.
6396        let starts_with_dot = trimmed_owned.starts_with('.')
6397            && !trimmed_owned.starts_with("..")
6398            && !trimmed_owned.starts_with(".;");
6399        let effective_depth = if starts_with_close {
6400            depth.saturating_sub(1)
6401        } else if starts_with_dot {
6402            depth.saturating_add(1)
6403        } else {
6404            depth
6405        } as usize;
6406
6407        // Build new line: indent × depth + stripped content.
6408        let new_line = format!("{}{}", indent_unit.repeat(effective_depth), trimmed_owned);
6409
6410        // Advance depth by this line's net bracket count (scan trimmed content).
6411        depth += bracket_net(&trimmed_owned);
6412        if depth < 0 {
6413            depth = 0;
6414        }
6415
6416        *line = new_line;
6417    }
6418
6419    // Restore cursor to the first non-blank of `top` (vim parity for `==`).
6420    ed.restore(lines, (top, 0));
6421    move_first_non_whitespace(ed);
6422    // Record the touched row range so the host can display a visual flash.
6423    ed.last_indent_range = Some((top, bot));
6424}
6425
6426fn toggle_case_str(s: &str) -> String {
6427    s.chars()
6428        .map(|c| {
6429            if c.is_lowercase() {
6430                c.to_uppercase().next().unwrap_or(c)
6431            } else if c.is_uppercase() {
6432                c.to_lowercase().next().unwrap_or(c)
6433            } else {
6434                c
6435            }
6436        })
6437        .collect()
6438}
6439
6440fn order(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) {
6441    if a <= b { (a, b) } else { (b, a) }
6442}
6443
6444/// Clamp the buffer cursor to normal-mode valid position: col may not
6445/// exceed `line.chars().count().saturating_sub(1)` (or 0 on an empty
6446/// line). Vim applies this clamp on every return to Normal mode after an
6447/// operator or Esc-from-insert.
6448fn clamp_cursor_to_normal_mode<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
6449    let (row, col) = ed.cursor();
6450    let line_chars = buf_line_chars(&ed.buffer, row);
6451    let max_col = line_chars.saturating_sub(1);
6452    if col > max_col {
6453        buf_set_cursor_rc(&mut ed.buffer, row, max_col);
6454        ed.push_buffer_cursor_to_textarea();
6455    }
6456}
6457
6458// ─── dd/cc/yy ──────────────────────────────────────────────────────────────
6459
6460/// Expand a linewise `[start, end]` row range so it fully covers every CLOSED
6461/// fold it overlaps — vim's rule that a linewise operator on a closed fold acts
6462/// on the whole fold. Loops until stable so nested closed folds are absorbed.
6463fn expand_linewise_over_closed_folds(
6464    buf: &hjkl_buffer::Buffer,
6465    mut start: usize,
6466    mut end: usize,
6467) -> (usize, usize) {
6468    let folds = buf.folds();
6469    if folds.is_empty() {
6470        return (start, end);
6471    }
6472    loop {
6473        let mut changed = false;
6474        for f in &folds {
6475            if !f.closed {
6476                continue;
6477            }
6478            // Does this closed fold overlap the current range?
6479            if f.start_row <= end && f.end_row >= start {
6480                if f.start_row < start {
6481                    start = f.start_row;
6482                    changed = true;
6483                }
6484                if f.end_row > end {
6485                    end = f.end_row;
6486                    changed = true;
6487                }
6488            }
6489        }
6490        if !changed {
6491            break;
6492        }
6493    }
6494    (start, end)
6495}
6496
6497fn execute_line_op<H: crate::types::Host>(
6498    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6499    op: Operator,
6500    count: usize,
6501) {
6502    let (row, col) = ed.cursor();
6503    let total = buf_row_count(&ed.buffer);
6504    // Vim: `[count]op` for a linewise operator implies a `count_` motion that
6505    // moves `count - 1` lines down. On the last line that motion can't move at
6506    // all, so the whole operator aborts (E16) — `2dd`/`2yy`/`5>>`/`5<<` on the
6507    // final line are no-ops, not "operate on the one remaining line". When the
6508    // cursor is above the last line the motion clamps to the buffer end instead.
6509    //
6510    // A trailing newline is stored as a phantom empty final row, so the last
6511    // *content* line is one above it; use that as the boundary.
6512    let last_content_row = if total >= 2
6513        && buf_line(&ed.buffer, total - 1)
6514            .map(|s| s.is_empty())
6515            .unwrap_or(false)
6516    {
6517        total - 2
6518    } else {
6519        total.saturating_sub(1)
6520    };
6521    if count >= 2 && row >= last_content_row {
6522        return;
6523    }
6524    let end_row = (row + count.saturating_sub(1)).min(total.saturating_sub(1));
6525
6526    // Vim: a linewise operator (`dd`/`yy`/`cc`/`>>`/…) with the cursor on a
6527    // CLOSED fold operates on the ENTIRE fold, not just the cursor line. Expand
6528    // the `[row, end_row]` range to cover any closed fold it touches (repeats
6529    // until stable so nested folds are absorbed too).
6530    let (row, end_row) = expand_linewise_over_closed_folds(&ed.buffer, row, end_row);
6531
6532    match op {
6533        Operator::Yank => {
6534            // yy must not move the cursor.
6535            let text = read_vim_range(ed, (row, col), (end_row, 0), RangeKind::Linewise);
6536            if !text.is_empty() {
6537                ed.record_yank_to_host(text.clone());
6538                ed.record_yank(text, true);
6539            }
6540            // Vim `:h '[` / `:h ']`: yy/Nyy — linewise yank; `[` =
6541            // (top_row, 0), `]` = (bot_row, last_col).
6542            let last_col = buf_line_chars(&ed.buffer, end_row).saturating_sub(1);
6543            ed.set_mark('[', (row, 0));
6544            ed.set_mark(']', (end_row, last_col));
6545            buf_set_cursor_rc(&mut ed.buffer, row, col);
6546            ed.push_buffer_cursor_to_textarea();
6547            ed.vim.mode = Mode::Normal;
6548        }
6549        Operator::Delete => {
6550            ed.push_undo();
6551            let deleted_through_last = end_row + 1 >= total;
6552            cut_vim_range(ed, (row, col), (end_row, 0), RangeKind::Linewise);
6553            // Vim's `dd` / `Ndd` leaves the cursor on the *first
6554            // non-blank* of the line that now occupies `row` — or, if
6555            // the deletion consumed the last line, the line above it.
6556            let total_after = buf_row_count(&ed.buffer);
6557            let raw_target = if deleted_through_last {
6558                row.saturating_sub(1).min(total_after.saturating_sub(1))
6559            } else {
6560                row.min(total_after.saturating_sub(1))
6561            };
6562            // Clamp off the trailing phantom empty row that arises from a
6563            // buffer with a trailing newline (stored as ["...", ""]). If
6564            // the target row is the trailing empty row and there is a real
6565            // content row above it, use that instead — matching vim's view
6566            // that the trailing `\n` is a terminator, not a separator.
6567            let target_row = if raw_target > 0
6568                && raw_target + 1 == total_after
6569                && buf_line(&ed.buffer, raw_target)
6570                    .map(|s| s.is_empty())
6571                    .unwrap_or(false)
6572            {
6573                raw_target - 1
6574            } else {
6575                raw_target
6576            };
6577            buf_set_cursor_rc(&mut ed.buffer, target_row, 0);
6578            ed.push_buffer_cursor_to_textarea();
6579            move_first_non_whitespace(ed);
6580            ed.sticky_col = Some(ed.cursor().1);
6581            ed.vim.mode = Mode::Normal;
6582            // Vim `:h '[` / `:h ']`: dd/Ndd — both marks park at the
6583            // post-delete cursor position (the join point).
6584            let pos = ed.cursor();
6585            ed.set_mark('[', pos);
6586            ed.set_mark(']', pos);
6587        }
6588        Operator::Change => {
6589            // `cc` / `3cc`: delegate to the shared linewise-change helper
6590            // which preserves the first line's indent, leaves one row open,
6591            // and enters insert mode.
6592            change_linewise_rows(ed, row, end_row);
6593        }
6594        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {
6595            // `gUU` / `guu` / `g~~` / `g??` — linewise case/rot13 transform over
6596            // [row, end_row]. Preserve cursor on `row` (first non-blank
6597            // lines up with vim's behaviour).
6598            apply_case_op_to_selection(ed, op, (row, col), (end_row, 0), RangeKind::Linewise);
6599            // After case-op on a linewise range vim puts the cursor on
6600            // the first non-blank of the starting line.
6601            move_first_non_whitespace(ed);
6602        }
6603        Operator::Indent | Operator::Outdent => {
6604            // `>>` / `N>>` / `<<` / `N<<` — linewise indent / outdent.
6605            ed.push_undo();
6606            if op == Operator::Indent {
6607                indent_rows(ed, row, end_row, 1);
6608            } else {
6609                outdent_rows(ed, row, end_row, 1);
6610            }
6611            ed.sticky_col = Some(ed.cursor().1);
6612            ed.vim.mode = Mode::Normal;
6613        }
6614        // No doubled form — `zfzf` is two consecutive `zf` chords.
6615        Operator::Fold => unreachable!("Fold has no line-op double"),
6616        Operator::Reflow => {
6617            // `gqq` / `Ngqq` — reflow `count` rows starting at the cursor.
6618            ed.push_undo();
6619            reflow_rows(ed, row, end_row);
6620            move_first_non_whitespace(ed);
6621            ed.sticky_col = Some(ed.cursor().1);
6622            ed.vim.mode = Mode::Normal;
6623        }
6624        Operator::ReflowKeepCursor => {
6625            // `gww` / `Ngww` — reflow `count` rows starting at the cursor,
6626            // but leave the cursor at the character it was on before reflow.
6627            let saved = ed.cursor();
6628            ed.push_undo();
6629            let (before, after) = reflow_rows_keep_cursor(ed, row, end_row);
6630            let (new_row, new_col) = reflow_keep_cursor(row, saved.0, saved.1, &before, &after);
6631            buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
6632            ed.push_buffer_cursor_to_textarea();
6633            ed.sticky_col = Some(new_col);
6634            ed.vim.mode = Mode::Normal;
6635        }
6636        Operator::AutoIndent => {
6637            // `==` / `N==` — auto-indent `count` rows starting at cursor.
6638            ed.push_undo();
6639            auto_indent_rows(ed, row, end_row);
6640            ed.sticky_col = Some(ed.cursor().1);
6641            ed.vim.mode = Mode::Normal;
6642        }
6643        Operator::Filter => {
6644            // Filter is dispatched through Editor::filter_range, not here.
6645        }
6646        Operator::Comment => {
6647            // Comment is dispatched through Editor::toggle_comment_range, not here.
6648            // The doubled `gcc` path calls toggle_comment_range directly in
6649            // apply_after_g, then records last_change. execute_line_op should
6650            // not be reached for Comment — no-op if it is.
6651        }
6652    }
6653}
6654
6655// ─── Visual mode operators ─────────────────────────────────────────────────
6656
6657pub(crate) fn apply_visual_operator<H: crate::types::Host>(
6658    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6659    op: Operator,
6660    count: usize,
6661) {
6662    // `count` is the number of indent levels for `>` / `<` (vim `2>` = two
6663    // shiftwidths); other visual operators ignore it.
6664    let levels = count.max(1);
6665    match ed.vim.mode {
6666        Mode::VisualLine => {
6667            let cursor_row = buf_cursor_pos(&ed.buffer).row;
6668            let top = cursor_row.min(ed.vim.visual_line_anchor);
6669            let bot = cursor_row.max(ed.vim.visual_line_anchor);
6670            ed.vim.yank_linewise = true;
6671            match op {
6672                Operator::Yank => {
6673                    let text = read_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
6674                    if !text.is_empty() {
6675                        ed.record_yank_to_host(text.clone());
6676                        ed.record_yank(text, true);
6677                    }
6678                    buf_set_cursor_rc(&mut ed.buffer, top, 0);
6679                    ed.push_buffer_cursor_to_textarea();
6680                    ed.vim.mode = Mode::Normal;
6681                }
6682                Operator::Delete => {
6683                    ed.push_undo();
6684                    cut_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
6685                    ed.vim.mode = Mode::Normal;
6686                }
6687                Operator::Change => {
6688                    // Vim `Vc` / `Vjc`: same linewise-change semantics as
6689                    // `cc` — preserve first line's indent, enter insert.
6690                    change_linewise_rows(ed, top, bot);
6691                }
6692                Operator::Uppercase
6693                | Operator::Lowercase
6694                | Operator::ToggleCase
6695                | Operator::Rot13 => {
6696                    let bot = buf_cursor_pos(&ed.buffer)
6697                        .row
6698                        .max(ed.vim.visual_line_anchor);
6699                    apply_case_op_to_selection(ed, op, (top, 0), (bot, 0), RangeKind::Linewise);
6700                    move_first_non_whitespace(ed);
6701                }
6702                Operator::Indent | Operator::Outdent => {
6703                    ed.push_undo();
6704                    let (cursor_row, _) = ed.cursor();
6705                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
6706                    if op == Operator::Indent {
6707                        indent_rows(ed, top, bot, levels);
6708                    } else {
6709                        outdent_rows(ed, top, bot, levels);
6710                    }
6711                    ed.vim.mode = Mode::Normal;
6712                }
6713                Operator::Reflow => {
6714                    ed.push_undo();
6715                    let (cursor_row, _) = ed.cursor();
6716                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
6717                    reflow_rows(ed, top, bot);
6718                    ed.vim.mode = Mode::Normal;
6719                }
6720                Operator::ReflowKeepCursor => {
6721                    let saved = ed.cursor();
6722                    ed.push_undo();
6723                    let (cursor_row, _) = ed.cursor();
6724                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
6725                    let (before, after) = reflow_rows_keep_cursor(ed, top, bot);
6726                    let (new_row, new_col) =
6727                        reflow_keep_cursor(top, saved.0, saved.1, &before, &after);
6728                    buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
6729                    ed.push_buffer_cursor_to_textarea();
6730                    ed.vim.mode = Mode::Normal;
6731                }
6732                Operator::AutoIndent => {
6733                    ed.push_undo();
6734                    let (cursor_row, _) = ed.cursor();
6735                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
6736                    auto_indent_rows(ed, top, bot);
6737                    ed.vim.mode = Mode::Normal;
6738                }
6739                // Filter is dispatched through Editor::filter_range, not here.
6740                Operator::Filter => {}
6741                // Comment is dispatched through the app layer (engine_actions.rs), not here.
6742                Operator::Comment => {}
6743                // Visual `zf` is handled inline in `handle_after_z`,
6744                // never routed through this dispatcher.
6745                Operator::Fold => unreachable!("Visual zf takes its own path"),
6746            }
6747        }
6748        Mode::Visual => {
6749            ed.vim.yank_linewise = false;
6750            let anchor = ed.vim.visual_anchor;
6751            let cursor = ed.cursor();
6752            let (top, bot) = order(anchor, cursor);
6753            match op {
6754                Operator::Yank => {
6755                    let text = read_vim_range(ed, top, bot, RangeKind::Inclusive);
6756                    if !text.is_empty() {
6757                        ed.record_yank_to_host(text.clone());
6758                        ed.record_yank(text, false);
6759                    }
6760                    buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
6761                    ed.push_buffer_cursor_to_textarea();
6762                    ed.vim.mode = Mode::Normal;
6763                }
6764                Operator::Delete => {
6765                    ed.push_undo();
6766                    cut_vim_range(ed, top, bot, RangeKind::Inclusive);
6767                    ed.vim.mode = Mode::Normal;
6768                }
6769                Operator::Change => {
6770                    ed.push_undo();
6771                    cut_vim_range(ed, top, bot, RangeKind::Inclusive);
6772                    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
6773                }
6774                Operator::Uppercase
6775                | Operator::Lowercase
6776                | Operator::ToggleCase
6777                | Operator::Rot13 => {
6778                    // Anchor stays where the visual selection started.
6779                    let anchor = ed.vim.visual_anchor;
6780                    let cursor = ed.cursor();
6781                    let (top, bot) = order(anchor, cursor);
6782                    apply_case_op_to_selection(ed, op, top, bot, RangeKind::Inclusive);
6783                }
6784                Operator::Indent | Operator::Outdent => {
6785                    ed.push_undo();
6786                    let anchor = ed.vim.visual_anchor;
6787                    let cursor = ed.cursor();
6788                    let (top, bot) = order(anchor, cursor);
6789                    if op == Operator::Indent {
6790                        indent_rows(ed, top.0, bot.0, levels);
6791                    } else {
6792                        outdent_rows(ed, top.0, bot.0, levels);
6793                    }
6794                    ed.vim.mode = Mode::Normal;
6795                }
6796                Operator::Reflow => {
6797                    ed.push_undo();
6798                    let anchor = ed.vim.visual_anchor;
6799                    let cursor = ed.cursor();
6800                    let (top, bot) = order(anchor, cursor);
6801                    reflow_rows(ed, top.0, bot.0);
6802                    ed.vim.mode = Mode::Normal;
6803                }
6804                Operator::ReflowKeepCursor => {
6805                    let saved = ed.cursor();
6806                    ed.push_undo();
6807                    let anchor = ed.vim.visual_anchor;
6808                    let cursor = ed.cursor();
6809                    let (top, bot) = order(anchor, cursor);
6810                    let (before, after) = reflow_rows_keep_cursor(ed, top.0, bot.0);
6811                    let (new_row, new_col) =
6812                        reflow_keep_cursor(top.0, saved.0, saved.1, &before, &after);
6813                    buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
6814                    ed.push_buffer_cursor_to_textarea();
6815                    ed.vim.mode = Mode::Normal;
6816                }
6817                Operator::AutoIndent => {
6818                    ed.push_undo();
6819                    let anchor = ed.vim.visual_anchor;
6820                    let cursor = ed.cursor();
6821                    let (top, bot) = order(anchor, cursor);
6822                    auto_indent_rows(ed, top.0, bot.0);
6823                    ed.vim.mode = Mode::Normal;
6824                }
6825                // Filter is dispatched through Editor::filter_range, not here.
6826                Operator::Filter => {}
6827                // Comment is dispatched through the app layer (engine_actions.rs), not here.
6828                Operator::Comment => {}
6829                Operator::Fold => unreachable!("Visual zf takes its own path"),
6830            }
6831        }
6832        Mode::VisualBlock => apply_block_operator(ed, op, levels),
6833        _ => {}
6834    }
6835}
6836
6837/// Compute `(top_row, bot_row, left_col, right_col)` for the current
6838/// VisualBlock selection. Columns are inclusive on both ends. Uses the
6839/// tracked virtual column (updated by h/l, preserved across j/k) so
6840/// ragged / empty rows don't collapse the block's width.
6841fn block_bounds<H: crate::types::Host>(
6842    ed: &Editor<hjkl_buffer::Buffer, H>,
6843) -> (usize, usize, usize, usize) {
6844    let (ar, ac) = ed.vim.block_anchor;
6845    let (cr, _) = ed.cursor();
6846    let cc = ed.vim.block_vcol;
6847    let top = ar.min(cr);
6848    let bot = ar.max(cr);
6849    let left = ac.min(cc);
6850    let right = ac.max(cc);
6851    (top, bot, left, right)
6852}
6853
6854/// Update the virtual column after a motion in VisualBlock mode.
6855/// Horizontal motions sync `block_vcol` to the new cursor column;
6856/// vertical / non-h/l motions leave it alone so the intended column
6857/// survives clamping to shorter lines.
6858pub(crate) fn update_block_vcol<H: crate::types::Host>(
6859    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6860    motion: &Motion,
6861) {
6862    match motion {
6863        Motion::Left
6864        | Motion::Right
6865        | Motion::SpaceFwd
6866        | Motion::BackspaceBack
6867        | Motion::WordFwd
6868        | Motion::BigWordFwd
6869        | Motion::WordBack
6870        | Motion::BigWordBack
6871        | Motion::WordEnd
6872        | Motion::BigWordEnd
6873        | Motion::WordEndBack
6874        | Motion::BigWordEndBack
6875        | Motion::LineStart
6876        | Motion::FirstNonBlank
6877        | Motion::LineEnd
6878        | Motion::Find { .. }
6879        | Motion::FindRepeat { .. }
6880        | Motion::MatchBracket => {
6881            ed.vim.block_vcol = ed.cursor().1;
6882        }
6883        // Up / Down / FileTop / FileBottom / Search — preserve vcol.
6884        _ => {}
6885    }
6886}
6887
6888/// Yank / delete / change / replace a rectangular selection. Yanked text
6889/// is stored as one string per row joined with `\n` so pasting reproduces
6890/// the block as sequential lines. (Vim's true block-paste reinserts as
6891/// columns; we render the content with our char-wise paste path.)
6892fn apply_block_operator<H: crate::types::Host>(
6893    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6894    op: Operator,
6895    count: usize,
6896) {
6897    let (top, bot, left, right) = block_bounds(ed);
6898    // Snapshot the block text for yank / clipboard.
6899    let yank = block_yank(ed, top, bot, left, right);
6900
6901    match op {
6902        Operator::Yank => {
6903            if !yank.is_empty() {
6904                ed.record_yank_to_host(yank.clone());
6905                ed.record_yank(yank, false);
6906            }
6907            ed.vim.mode = Mode::Normal;
6908            ed.jump_cursor(top, left);
6909        }
6910        Operator::Delete => {
6911            ed.push_undo();
6912            delete_block_contents(ed, top, bot, left, right);
6913            if !yank.is_empty() {
6914                ed.record_yank_to_host(yank.clone());
6915                ed.record_delete(yank, false);
6916            }
6917            ed.vim.mode = Mode::Normal;
6918            ed.jump_cursor(top, left);
6919        }
6920        Operator::Change => {
6921            ed.push_undo();
6922            delete_block_contents(ed, top, bot, left, right);
6923            if !yank.is_empty() {
6924                ed.record_yank_to_host(yank.clone());
6925                ed.record_delete(yank, false);
6926            }
6927            ed.jump_cursor(top, left);
6928            begin_insert_noundo(
6929                ed,
6930                1,
6931                InsertReason::BlockChange {
6932                    top,
6933                    bot,
6934                    col: left,
6935                },
6936            );
6937        }
6938        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {
6939            ed.push_undo();
6940            transform_block_case(ed, op, top, bot, left, right);
6941            ed.vim.mode = Mode::Normal;
6942            ed.jump_cursor(top, left);
6943        }
6944        Operator::Indent | Operator::Outdent => {
6945            // VisualBlock `>` / `<` falls back to linewise indent over
6946            // the block's row range — vim does the same (column-wise
6947            // indent/outdent doesn't make sense).
6948            ed.push_undo();
6949            if op == Operator::Indent {
6950                indent_rows(ed, top, bot, count.max(1));
6951            } else {
6952                outdent_rows(ed, top, bot, count.max(1));
6953            }
6954            ed.vim.mode = Mode::Normal;
6955        }
6956        Operator::Fold => unreachable!("Visual zf takes its own path"),
6957        Operator::Reflow => {
6958            // Reflow over the block falls back to linewise reflow over
6959            // the row range — column slicing for `gq` doesn't make
6960            // sense.
6961            ed.push_undo();
6962            reflow_rows(ed, top, bot);
6963            ed.vim.mode = Mode::Normal;
6964        }
6965        Operator::ReflowKeepCursor => {
6966            // `gw` over a block: same fallback as `gq` but restore cursor.
6967            let saved = ed.cursor();
6968            ed.push_undo();
6969            let (before, after) = reflow_rows_keep_cursor(ed, top, bot);
6970            let (new_row, new_col) = reflow_keep_cursor(top, saved.0, saved.1, &before, &after);
6971            buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
6972            ed.push_buffer_cursor_to_textarea();
6973            ed.vim.mode = Mode::Normal;
6974        }
6975        Operator::AutoIndent => {
6976            // AutoIndent over the block falls back to linewise
6977            // auto-indent over the row range.
6978            ed.push_undo();
6979            auto_indent_rows(ed, top, bot);
6980            ed.vim.mode = Mode::Normal;
6981        }
6982        // Filter is dispatched through Editor::filter_range, not here.
6983        Operator::Filter => {}
6984        // Comment is dispatched through the app layer (engine_actions.rs), not here.
6985        Operator::Comment => {}
6986    }
6987}
6988
6989/// In-place case transform over the rectangular block
6990/// `(top..=bot, left..=right)`. Rows shorter than `left` are left
6991/// untouched — vim behaves the same way (ragged blocks).
6992fn transform_block_case<H: crate::types::Host>(
6993    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6994    op: Operator,
6995    top: usize,
6996    bot: usize,
6997    left: usize,
6998    right: usize,
6999) {
7000    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
7001    for r in top..=bot.min(lines.len().saturating_sub(1)) {
7002        let chars: Vec<char> = lines[r].chars().collect();
7003        if left >= chars.len() {
7004            continue;
7005        }
7006        let end = (right + 1).min(chars.len());
7007        let head: String = chars[..left].iter().collect();
7008        let mid: String = chars[left..end].iter().collect();
7009        let tail: String = chars[end..].iter().collect();
7010        let transformed = match op {
7011            Operator::Uppercase => mid.to_uppercase(),
7012            Operator::Lowercase => mid.to_lowercase(),
7013            Operator::ToggleCase => toggle_case_str(&mid),
7014            Operator::Rot13 => rot13_str(&mid),
7015            _ => mid,
7016        };
7017        lines[r] = format!("{head}{transformed}{tail}");
7018    }
7019    let saved_yank = ed.yank().to_string();
7020    let saved_linewise = ed.vim.yank_linewise;
7021    ed.restore(lines, (top, left));
7022    ed.set_yank(saved_yank);
7023    ed.vim.yank_linewise = saved_linewise;
7024}
7025
7026fn block_yank<H: crate::types::Host>(
7027    ed: &Editor<hjkl_buffer::Buffer, H>,
7028    top: usize,
7029    bot: usize,
7030    left: usize,
7031    right: usize,
7032) -> String {
7033    let rope = crate::types::Query::rope(&ed.buffer);
7034    let n = rope.len_lines();
7035    let mut rows: Vec<String> = Vec::new();
7036    for r in top..=bot {
7037        if r >= n {
7038            break;
7039        }
7040        let line = rope_line_to_str(&rope, r);
7041        let chars: Vec<char> = line.chars().collect();
7042        let end = (right + 1).min(chars.len());
7043        if left >= chars.len() {
7044            rows.push(String::new());
7045        } else {
7046            rows.push(chars[left..end].iter().collect());
7047        }
7048    }
7049    rows.join("\n")
7050}
7051
7052fn delete_block_contents<H: crate::types::Host>(
7053    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7054    top: usize,
7055    bot: usize,
7056    left: usize,
7057    right: usize,
7058) {
7059    use hjkl_buffer::{Edit, MotionKind, Position};
7060    ed.sync_buffer_content_from_textarea();
7061    let last_row = bot.min(buf_row_count(&ed.buffer).saturating_sub(1));
7062    if last_row < top {
7063        return;
7064    }
7065    ed.mutate_edit(Edit::DeleteRange {
7066        start: Position::new(top, left),
7067        end: Position::new(last_row, right),
7068        kind: MotionKind::Block,
7069    });
7070    ed.push_buffer_cursor_to_textarea();
7071}
7072
7073/// Replace each character cell in the block with `ch`.
7074pub(crate) fn block_replace<H: crate::types::Host>(
7075    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7076    ch: char,
7077) {
7078    let (top, bot, left, right) = block_bounds(ed);
7079    ed.push_undo();
7080    ed.sync_buffer_content_from_textarea();
7081    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
7082    for r in top..=bot.min(lines.len().saturating_sub(1)) {
7083        let chars: Vec<char> = lines[r].chars().collect();
7084        if left >= chars.len() {
7085            continue;
7086        }
7087        let end = (right + 1).min(chars.len());
7088        let before: String = chars[..left].iter().collect();
7089        let middle: String = std::iter::repeat_n(ch, end - left).collect();
7090        let after: String = chars[end..].iter().collect();
7091        lines[r] = format!("{before}{middle}{after}");
7092    }
7093    reset_textarea_lines(ed, lines);
7094    ed.vim.mode = Mode::Normal;
7095    ed.jump_cursor(top, left);
7096}
7097
7098/// Replace buffer content with `lines` while preserving the cursor.
7099/// Used by indent / outdent / block_replace to wholesale rewrite
7100/// rows without going through the per-edit funnel.
7101fn reset_textarea_lines<H: crate::types::Host>(
7102    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7103    lines: Vec<String>,
7104) {
7105    let cursor = ed.cursor();
7106    crate::types::BufferEdit::replace_all(&mut ed.buffer, &lines.join("\n"));
7107    buf_set_cursor_rc(&mut ed.buffer, cursor.0, cursor.1);
7108    ed.mark_content_dirty();
7109}
7110
7111// ─── Visual-line helpers ───────────────────────────────────────────────────
7112
7113// ─── Text-object range computation ─────────────────────────────────────────
7114
7115/// Cursor position as `(row, col)`.
7116type Pos = (usize, usize);
7117
7118/// Returns `(start, end, kind)` where `end` is *exclusive* (one past the
7119/// last character to act on). `kind` is `Linewise` for line-oriented text
7120/// objects like paragraphs and `Exclusive` otherwise.
7121pub(crate) fn text_object_range<H: crate::types::Host>(
7122    ed: &Editor<hjkl_buffer::Buffer, H>,
7123    obj: TextObject,
7124    inner: bool,
7125    count: usize,
7126) -> Option<(Pos, Pos, RangeKind)> {
7127    match obj {
7128        TextObject::Word { big } => {
7129            word_text_object(ed, inner, big).map(|(s, e)| (s, e, RangeKind::Exclusive))
7130        }
7131        TextObject::Quote(q) => {
7132            quote_text_object(ed, q, inner).map(|(s, e)| (s, e, RangeKind::Exclusive))
7133        }
7134        TextObject::Bracket(open) => bracket_text_object(ed, open, inner, count),
7135        TextObject::Paragraph => {
7136            paragraph_text_object(ed, inner).map(|(s, e)| (s, e, RangeKind::Linewise))
7137        }
7138        TextObject::XmlTag => tag_text_object(ed, inner).map(|(s, e)| (s, e, RangeKind::Exclusive)),
7139        TextObject::Sentence => {
7140            sentence_text_object(ed, inner).map(|(s, e)| (s, e, RangeKind::Exclusive))
7141        }
7142    }
7143}
7144
7145/// `(` / `)` — walk to the next sentence boundary in `forward` direction.
7146/// Returns `(row, col)` of the boundary's first non-whitespace cell, or
7147/// `None` when already at the buffer's edge in that direction.
7148fn sentence_boundary<H: crate::types::Host>(
7149    ed: &Editor<hjkl_buffer::Buffer, H>,
7150    forward: bool,
7151) -> Option<(usize, usize)> {
7152    let rope = crate::types::Query::rope(&ed.buffer);
7153    let n_lines = rope.len_lines();
7154    if n_lines == 0 {
7155        return None;
7156    }
7157    // Per-line char counts (excluding trailing \n) for pos↔idx conversion.
7158    let line_lens: Vec<usize> = (0..n_lines)
7159        .map(|r| rope_line_to_str(&rope, r).chars().count())
7160        .collect();
7161    let pos_to_idx = |pos: (usize, usize)| -> usize {
7162        let idx: usize = line_lens.iter().take(pos.0).map(|&len| len + 1).sum();
7163        idx + pos.1
7164    };
7165    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
7166        for (r, &len) in line_lens.iter().enumerate() {
7167            if idx <= len {
7168                return (r, idx);
7169            }
7170            idx -= len + 1;
7171        }
7172        let last = n_lines.saturating_sub(1);
7173        (last, line_lens[last])
7174    };
7175    // Build flat char vector: rope chars already include \n between lines.
7176    // ropey's last line has no trailing \n; intermediate ones do.
7177    let mut chars: Vec<char> = rope.chars().collect();
7178    // Strip a trailing \n if ropey emitted one on the final line.
7179    if chars.last() == Some(&'\n') {
7180        chars.pop();
7181    }
7182    if chars.is_empty() {
7183        return None;
7184    }
7185    let total = chars.len();
7186    let cursor_idx = pos_to_idx(ed.cursor()).min(total - 1);
7187    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
7188
7189    if forward {
7190        // Walk forward looking for a terminator run followed by
7191        // whitespace; land on the first non-whitespace cell after.
7192        let mut i = cursor_idx + 1;
7193        while i < total {
7194            if is_terminator(chars[i]) {
7195                while i + 1 < total && is_terminator(chars[i + 1]) {
7196                    i += 1;
7197                }
7198                if i + 1 >= total {
7199                    return None;
7200                }
7201                if chars[i + 1].is_whitespace() {
7202                    let mut j = i + 1;
7203                    while j < total && chars[j].is_whitespace() {
7204                        j += 1;
7205                    }
7206                    if j >= total {
7207                        return None;
7208                    }
7209                    return Some(idx_to_pos(j));
7210                }
7211            }
7212            i += 1;
7213        }
7214        None
7215    } else {
7216        // Walk backward to find the start of the current sentence (if
7217        // we're already at the start, jump to the previous sentence's
7218        // start instead).
7219        let find_start = |from: usize| -> Option<usize> {
7220            let mut start = from;
7221            while start > 0 {
7222                let prev = chars[start - 1];
7223                if prev.is_whitespace() {
7224                    let mut k = start - 1;
7225                    while k > 0 && chars[k - 1].is_whitespace() {
7226                        k -= 1;
7227                    }
7228                    if k > 0 && is_terminator(chars[k - 1]) {
7229                        break;
7230                    }
7231                }
7232                start -= 1;
7233            }
7234            while start < total && chars[start].is_whitespace() {
7235                start += 1;
7236            }
7237            (start < total).then_some(start)
7238        };
7239        let current_start = find_start(cursor_idx)?;
7240        if current_start < cursor_idx {
7241            return Some(idx_to_pos(current_start));
7242        }
7243        // Already at the sentence start — step over the boundary into
7244        // the previous sentence and find its start.
7245        let mut k = current_start;
7246        while k > 0 && chars[k - 1].is_whitespace() {
7247            k -= 1;
7248        }
7249        if k == 0 {
7250            return None;
7251        }
7252        let prev_start = find_start(k - 1)?;
7253        Some(idx_to_pos(prev_start))
7254    }
7255}
7256
7257/// `is` / `as` — sentence: text up to and including the next sentence
7258/// terminator (`.`, `?`, `!`). Vim treats `.`/`?`/`!` followed by
7259/// whitespace (or end-of-line) as a boundary; runs of consecutive
7260/// terminators stay attached to the same sentence. `as` extends to
7261/// include trailing whitespace; `is` does not.
7262fn sentence_text_object<H: crate::types::Host>(
7263    ed: &Editor<hjkl_buffer::Buffer, H>,
7264    inner: bool,
7265) -> Option<((usize, usize), (usize, usize))> {
7266    let rope = crate::types::Query::rope(&ed.buffer);
7267    let n_lines = rope.len_lines();
7268    if n_lines == 0 {
7269        return None;
7270    }
7271    // Flatten the buffer so a sentence can span lines (vim's behaviour).
7272    // Newlines count as whitespace for boundary detection.
7273    let line_lens: Vec<usize> = (0..n_lines)
7274        .map(|r| rope_line_to_str(&rope, r).chars().count())
7275        .collect();
7276    let pos_to_idx = |pos: (usize, usize)| -> usize {
7277        let idx: usize = line_lens.iter().take(pos.0).map(|&len| len + 1).sum();
7278        idx + pos.1
7279    };
7280    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
7281        for (r, &len) in line_lens.iter().enumerate() {
7282            if idx <= len {
7283                return (r, idx);
7284            }
7285            idx -= len + 1;
7286        }
7287        let last = n_lines.saturating_sub(1);
7288        (last, line_lens[last])
7289    };
7290    let mut chars: Vec<char> = rope.chars().collect();
7291    if chars.last() == Some(&'\n') {
7292        chars.pop();
7293    }
7294    if chars.is_empty() {
7295        return None;
7296    }
7297
7298    let cursor_idx = pos_to_idx(ed.cursor()).min(chars.len() - 1);
7299    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
7300
7301    // Walk backward from cursor to find the start of the current
7302    // sentence. A boundary is: whitespace immediately after a run of
7303    // terminators (or start-of-buffer).
7304    let mut start = cursor_idx;
7305    while start > 0 {
7306        let prev = chars[start - 1];
7307        if prev.is_whitespace() {
7308            // Check if the whitespace follows a terminator — if so,
7309            // we've crossed a sentence boundary; the sentence begins
7310            // at the first non-whitespace cell *after* this run.
7311            let mut k = start - 1;
7312            while k > 0 && chars[k - 1].is_whitespace() {
7313                k -= 1;
7314            }
7315            if k > 0 && is_terminator(chars[k - 1]) {
7316                break;
7317            }
7318        }
7319        start -= 1;
7320    }
7321    // Skip leading whitespace (vim doesn't include it in the
7322    // sentence body).
7323    while start < chars.len() && chars[start].is_whitespace() {
7324        start += 1;
7325    }
7326    if start >= chars.len() {
7327        return None;
7328    }
7329
7330    // Walk forward to the sentence end (last terminator before the
7331    // next whitespace boundary).
7332    let mut end = start;
7333    while end < chars.len() {
7334        if is_terminator(chars[end]) {
7335            // Consume any consecutive terminators (e.g. `?!`).
7336            while end + 1 < chars.len() && is_terminator(chars[end + 1]) {
7337                end += 1;
7338            }
7339            // If followed by whitespace or end-of-buffer, that's the
7340            // boundary.
7341            if end + 1 >= chars.len() || chars[end + 1].is_whitespace() {
7342                break;
7343            }
7344        }
7345        end += 1;
7346    }
7347    // Inclusive end → exclusive end_idx.
7348    let end_idx = (end + 1).min(chars.len());
7349
7350    let final_end = if inner {
7351        end_idx
7352    } else {
7353        // `as`: include trailing whitespace (but stop before the next
7354        // newline so we don't gobble a paragraph break — vim keeps
7355        // sentences within a paragraph for the trailing-ws extension).
7356        let mut e = end_idx;
7357        while e < chars.len() && chars[e].is_whitespace() && chars[e] != '\n' {
7358            e += 1;
7359        }
7360        e
7361    };
7362
7363    Some((idx_to_pos(start), idx_to_pos(final_end)))
7364}
7365
7366/// `it` / `at` — XML tag pair text object. Builds a flat char index of
7367/// the buffer, walks `<...>` tokens to pair tags via a stack, and
7368/// returns the innermost pair containing the cursor.
7369fn tag_text_object<H: crate::types::Host>(
7370    ed: &Editor<hjkl_buffer::Buffer, H>,
7371    inner: bool,
7372) -> Option<((usize, usize), (usize, usize))> {
7373    let rope = crate::types::Query::rope(&ed.buffer);
7374    let n_lines = rope.len_lines();
7375    if n_lines == 0 {
7376        return None;
7377    }
7378    // Flatten char positions so we can compare cursor against tag
7379    // ranges without per-row arithmetic. `\n` between lines counts as
7380    // a single char.
7381    let line_lens: Vec<usize> = (0..n_lines)
7382        .map(|r| rope_line_to_str(&rope, r).chars().count())
7383        .collect();
7384    let pos_to_idx = |pos: (usize, usize)| -> usize {
7385        let idx: usize = line_lens.iter().take(pos.0).map(|&len| len + 1).sum();
7386        idx + pos.1
7387    };
7388    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
7389        for (r, &len) in line_lens.iter().enumerate() {
7390            if idx <= len {
7391                return (r, idx);
7392            }
7393            idx -= len + 1;
7394        }
7395        let last = n_lines.saturating_sub(1);
7396        (last, line_lens[last])
7397    };
7398    let mut chars: Vec<char> = rope.chars().collect();
7399    if chars.last() == Some(&'\n') {
7400        chars.pop();
7401    }
7402    let cursor_idx = pos_to_idx(ed.cursor());
7403
7404    // Walk `<...>` tokens. Track open tags on a stack; on a matching
7405    // close pop and consider the pair a candidate when the cursor lies
7406    // inside its content range. Innermost wins (replace whenever a
7407    // tighter range turns up). Also track the first complete pair that
7408    // starts at or after the cursor so we can fall back to a forward
7409    // scan (targets.vim-style) when the cursor isn't inside any tag.
7410    let mut stack: Vec<(usize, usize, String)> = Vec::new(); // (open_start, content_start, name)
7411    let mut innermost: Option<(usize, usize, usize, usize)> = None;
7412    let mut next_after: Option<(usize, usize, usize, usize)> = None;
7413    let mut i = 0;
7414    while i < chars.len() {
7415        if chars[i] != '<' {
7416            i += 1;
7417            continue;
7418        }
7419        let mut j = i + 1;
7420        while j < chars.len() && chars[j] != '>' {
7421            j += 1;
7422        }
7423        if j >= chars.len() {
7424            break;
7425        }
7426        let inside: String = chars[i + 1..j].iter().collect();
7427        let close_end = j + 1;
7428        let trimmed = inside.trim();
7429        if trimmed.starts_with('!') || trimmed.starts_with('?') {
7430            i = close_end;
7431            continue;
7432        }
7433        if let Some(rest) = trimmed.strip_prefix('/') {
7434            let name = rest.split_whitespace().next().unwrap_or("").to_string();
7435            if !name.is_empty()
7436                && let Some(stack_idx) = stack.iter().rposition(|(_, _, n)| *n == name)
7437            {
7438                let (open_start, content_start, _) = stack[stack_idx].clone();
7439                stack.truncate(stack_idx);
7440                let content_end = i;
7441                let candidate = (open_start, content_start, content_end, close_end);
7442                // A pair encloses the cursor when the cursor lies anywhere
7443                // within the whole pair span — including ON the open or close
7444                // tag itself (vim `it`/`at` operate on the tag under the
7445                // cursor, not just its content). Innermost (tightest span)
7446                // wins; closes are seen innermost-first so the first enclosing
7447                // candidate is already the tightest.
7448                if cursor_idx >= open_start && cursor_idx < close_end {
7449                    innermost = match innermost {
7450                        Some((os, _, _, ce)) if os <= open_start && close_end <= ce => {
7451                            Some(candidate)
7452                        }
7453                        None => Some(candidate),
7454                        existing => existing,
7455                    };
7456                } else if open_start >= cursor_idx && next_after.is_none() {
7457                    next_after = Some(candidate);
7458                }
7459            }
7460        } else if !trimmed.ends_with('/') {
7461            let name: String = trimmed
7462                .split(|c: char| c.is_whitespace() || c == '/')
7463                .next()
7464                .unwrap_or("")
7465                .to_string();
7466            if !name.is_empty() {
7467                stack.push((i, close_end, name));
7468            }
7469        }
7470        i = close_end;
7471    }
7472
7473    let (open_start, content_start, content_end, close_end) = innermost.or(next_after)?;
7474    if inner {
7475        Some((idx_to_pos(content_start), idx_to_pos(content_end)))
7476    } else {
7477        Some((idx_to_pos(open_start), idx_to_pos(close_end)))
7478    }
7479}
7480
7481fn is_wordchar(c: char) -> bool {
7482    c.is_alphanumeric() || c == '_'
7483}
7484
7485// `is_keyword_char` lives in hjkl-buffer (used by word motions);
7486// engine re-uses it via `hjkl_buffer::is_keyword_char` so there's
7487// one parser, one default, one bug surface.
7488pub(crate) use hjkl_buffer::is_keyword_char;
7489
7490/// Classify a vim abbreviation lhs into its type.
7491///
7492/// - **Full**: every char in `lhs` is a keyword char (full-id).
7493/// - **End**: the last char is a keyword char, at least one other is not (end-id).
7494/// - **None**: the last char is a non-keyword char (non-id).
7495#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7496pub(crate) enum AbbrevKind {
7497    /// All keyword chars (full-id).
7498    Full,
7499    /// Last char keyword, others include non-keyword (end-id).
7500    End,
7501    /// Last char is non-keyword (non-id).
7502    NonKw,
7503}
7504
7505pub(crate) fn abbrev_kind(lhs: &str, iskeyword: &str) -> AbbrevKind {
7506    let chars: Vec<char> = lhs.chars().collect();
7507    if chars.is_empty() {
7508        return AbbrevKind::NonKw;
7509    }
7510    let last = *chars.last().unwrap();
7511    let last_is_kw = is_keyword_char(last, iskeyword);
7512    if !last_is_kw {
7513        return AbbrevKind::NonKw;
7514    }
7515    // last is keyword — check if all chars are keyword
7516    let all_kw = chars.iter().all(|&c| is_keyword_char(c, iskeyword));
7517    if all_kw {
7518        AbbrevKind::Full
7519    } else {
7520        AbbrevKind::End
7521    }
7522}
7523
7524/// Try to match and expand an abbreviation given the text before the cursor.
7525///
7526/// # Parameters
7527/// - `abbrevs` — the active abbreviation table (insert-mode entries).
7528/// - `line_before` — the text on the current line *before* the cursor (char slice).
7529/// - `mincol` — first column index (0-based, char-indexed) that belongs to the
7530///   current insert session on the **same row as the cursor**.  Chars before
7531///   `mincol` were in the buffer before insert mode started and must NOT be
7532///   consumed as part of the lhs.  When the cursor is on a different row than
7533///   `start_row`, `mincol` is treated as 0 (the entire line was typed in this
7534///   session).
7535/// - `trigger` — what the user did (typed a non-kw char, pressed CR/Esc/C-]).
7536/// - `iskeyword` — the active iskeyword spec string.
7537///
7538/// Returns `Some((lhs_char_len, rhs))` on a match, where `lhs_char_len` is the
7539/// number of characters to delete before the cursor (the lhs), and `rhs` is the
7540/// text to insert in their place.  Returns `None` when no abbreviation matches.
7541pub(crate) fn try_abbrev_expand(
7542    abbrevs: &[Abbrev],
7543    line_before: &str,
7544    mincol: usize,
7545    trigger: AbbrevTrigger,
7546    iskeyword: &str,
7547) -> Option<(usize, String)> {
7548    let chars: Vec<char> = line_before.chars().collect();
7549    let cursor_col = chars.len(); // col of the cursor (0-based)
7550
7551    for abbrev in abbrevs {
7552        if !abbrev.insert {
7553            continue;
7554        }
7555        let lhs_chars: Vec<char> = abbrev.lhs.chars().collect();
7556        if lhs_chars.is_empty() {
7557            continue;
7558        }
7559        let lhs_len = lhs_chars.len();
7560
7561        // Determine the lhs type.
7562        let kind = abbrev_kind(&abbrev.lhs, iskeyword);
7563
7564        // Trigger rules by lhs type.
7565        match kind {
7566            AbbrevKind::Full | AbbrevKind::End => {
7567                // full-id / end-id: trigger char must be a NON-keyword char
7568                // (space, punctuation, CR, Esc, C-]).
7569                let trigger_char_is_kw = match trigger {
7570                    AbbrevTrigger::NonKeyword(c) => is_keyword_char(c, iskeyword),
7571                    AbbrevTrigger::CtrlBracket | AbbrevTrigger::Cr | AbbrevTrigger::Esc => false,
7572                };
7573                if trigger_char_is_kw {
7574                    // A keyword trigger char would extend the word — no expand.
7575                    continue;
7576                }
7577            }
7578            AbbrevKind::NonKw => {
7579                // non-id: only expand on CR, Esc, C-].  NOT on regular typed chars.
7580                match trigger {
7581                    AbbrevTrigger::Cr | AbbrevTrigger::Esc | AbbrevTrigger::CtrlBracket => {}
7582                    AbbrevTrigger::NonKeyword(_) => continue,
7583                }
7584            }
7585        }
7586
7587        // Check that the text before the cursor ends with the lhs.
7588        if cursor_col < lhs_len {
7589            continue;
7590        }
7591        let lhs_start_col = cursor_col - lhs_len;
7592
7593        // Enforce mincol: the lhs must not start before the insert-start column.
7594        if lhs_start_col < mincol {
7595            continue;
7596        }
7597
7598        // Compare chars.
7599        let text_slice: &[char] = &chars[lhs_start_col..cursor_col];
7600        if text_slice != lhs_chars.as_slice() {
7601            continue;
7602        }
7603
7604        // Check "front" rule: the char immediately before the lhs.
7605        if lhs_start_col > 0 {
7606            let ch_before = chars[lhs_start_col - 1];
7607            match kind {
7608                AbbrevKind::Full => {
7609                    // full-id: char before lhs must be a non-keyword char.
7610                    // Single-char full-id exception: if the char before is a
7611                    // non-keyword char that is NOT space/tab, it is NOT recognised
7612                    // (vim `:h abbreviations`: "A word in front of a full-id abbrev
7613                    // is a non-keyword char; but a single char abbrev is not
7614                    // recognised after a non-blank, non-keyword char").
7615                    // Actually vim's rule: full-id is not recognised if the char
7616                    // before is a NON-keyword char other than space/tab AND the lhs
7617                    // is a single keyword char. For multi-char full-id the rule is
7618                    // just "char before must be non-keyword".
7619                    if is_keyword_char(ch_before, iskeyword) {
7620                        continue; // char before is keyword → lhs is part of a longer word
7621                    }
7622                    if lhs_len == 1 && ch_before != ' ' && ch_before != '\t' {
7623                        // single-char full-id: non-blank non-keyword before → skip
7624                        continue;
7625                    }
7626                }
7627                AbbrevKind::End => {
7628                    // end-id: no constraint on the char before (any char is fine,
7629                    // including keyword chars — the non-keyword prefix of the lhs
7630                    // acts as the boundary).
7631                }
7632                AbbrevKind::NonKw => {
7633                    // non-id: the char before the lhs must be blank (space/tab) or
7634                    // it must be the start of the typed portion (mincol boundary).
7635                    if ch_before != ' ' && ch_before != '\t' {
7636                        continue;
7637                    }
7638                }
7639            }
7640        }
7641        // lhs_start_col == 0 means the lhs starts at the very beginning of the
7642        // line (or at the insert-start position); all types accept this.
7643
7644        return Some((lhs_len, abbrev.rhs.clone()));
7645    }
7646
7647    None
7648}
7649
7650/// Check abbreviations and apply the expansion if a match is found.
7651///
7652/// Reads the current cursor position and the text before it, calls
7653/// `try_abbrev_expand`, and if a match is found, deletes the `lhs` chars
7654/// and inserts the `rhs`. Returns `true` if an expansion was applied.
7655///
7656/// `trigger` is what the user did; the trigger char itself is NOT inserted
7657/// here — the caller inserts it (or not, in the case of `C-]`).
7658pub(crate) fn check_and_apply_abbrev<H: crate::types::Host>(
7659    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7660    trigger: AbbrevTrigger,
7661) -> bool {
7662    use hjkl_buffer::{Edit, Position};
7663
7664    // Collect the data we need without holding borrows.
7665    let cursor = buf_cursor_pos(&ed.buffer);
7666    let row = cursor.row;
7667    let col = cursor.col;
7668    let line_before: String = {
7669        let line = buf_line(&ed.buffer, row).unwrap_or_default();
7670        line.chars().take(col).collect()
7671    };
7672    let (mincol, on_start_row) = if let Some(ref s) = ed.vim.insert_session {
7673        if row == s.start_row {
7674            (s.start_col, true)
7675        } else {
7676            (0, false)
7677        }
7678    } else {
7679        (0, false)
7680    };
7681    // If cursor is before the insert start column on the same row, no lhs possible.
7682    if on_start_row && col <= mincol {
7683        return false;
7684    }
7685
7686    let iskeyword = ed.settings.iskeyword.clone();
7687    let abbrevs = ed.vim.abbrevs.clone();
7688
7689    let Some((lhs_len, rhs)) =
7690        try_abbrev_expand(&abbrevs, &line_before, mincol, trigger, &iskeyword)
7691    else {
7692        return false;
7693    };
7694
7695    // Delete `lhs_len` chars before the cursor.
7696    let lhs_start = col.saturating_sub(lhs_len);
7697    if lhs_len > 0 {
7698        ed.mutate_edit(Edit::DeleteRange {
7699            start: Position::new(row, lhs_start),
7700            end: Position::new(row, col),
7701            kind: hjkl_buffer::MotionKind::Char,
7702        });
7703    }
7704
7705    // Insert rhs at the (now updated) cursor position.
7706    let insert_pos = Position::new(row, lhs_start);
7707    if !rhs.is_empty() {
7708        ed.mutate_edit(Edit::InsertStr {
7709            at: insert_pos,
7710            text: rhs.clone(),
7711        });
7712    }
7713
7714    // Move cursor to end of inserted rhs.
7715    let new_col = lhs_start + rhs.chars().count();
7716    buf_set_cursor_rc(&mut ed.buffer, row, new_col);
7717    ed.push_buffer_cursor_to_textarea();
7718
7719    true
7720}
7721
7722fn word_text_object<H: crate::types::Host>(
7723    ed: &Editor<hjkl_buffer::Buffer, H>,
7724    inner: bool,
7725    big: bool,
7726) -> Option<((usize, usize), (usize, usize))> {
7727    let (row, col) = ed.cursor();
7728    let line = buf_line(&ed.buffer, row)?;
7729    let chars: Vec<char> = line.chars().collect();
7730    if chars.is_empty() {
7731        return None;
7732    }
7733    let at = col.min(chars.len().saturating_sub(1));
7734    let classify = |c: char| -> u8 {
7735        if c.is_whitespace() {
7736            0
7737        } else if big || is_wordchar(c) {
7738            1
7739        } else {
7740            2
7741        }
7742    };
7743    let cls = classify(chars[at]);
7744    let mut start = at;
7745    while start > 0 && classify(chars[start - 1]) == cls {
7746        start -= 1;
7747    }
7748    let mut end = at;
7749    while end + 1 < chars.len() && classify(chars[end + 1]) == cls {
7750        end += 1;
7751    }
7752    // Byte-offset helpers.
7753    let char_byte = |i: usize| {
7754        if i >= chars.len() {
7755            line.len()
7756        } else {
7757            line.char_indices().nth(i).map(|(b, _)| b).unwrap_or(0)
7758        }
7759    };
7760    let mut start_col = char_byte(start);
7761    // Exclusive end: byte index of char AFTER the last-included char.
7762    let mut end_col = char_byte(end + 1);
7763    if !inner {
7764        // `aw` — include trailing whitespace; if there's no trailing ws, absorb leading ws.
7765        let mut t = end + 1;
7766        let mut included_trailing = false;
7767        while t < chars.len() && chars[t].is_whitespace() {
7768            included_trailing = true;
7769            t += 1;
7770        }
7771        if included_trailing {
7772            end_col = char_byte(t);
7773        } else {
7774            let mut s = start;
7775            while s > 0 && chars[s - 1].is_whitespace() {
7776                s -= 1;
7777            }
7778            start_col = char_byte(s);
7779        }
7780    }
7781    Some(((row, start_col), (row, end_col)))
7782}
7783
7784fn quote_text_object<H: crate::types::Host>(
7785    ed: &Editor<hjkl_buffer::Buffer, H>,
7786    q: char,
7787    inner: bool,
7788) -> Option<((usize, usize), (usize, usize))> {
7789    let (row, col) = ed.cursor();
7790    let line = buf_line(&ed.buffer, row)?;
7791    let bytes = line.as_bytes();
7792    let q_byte = q as u8;
7793    // Find opening and closing quote on the same line.
7794    let mut positions: Vec<usize> = Vec::new();
7795    for (i, &b) in bytes.iter().enumerate() {
7796        if b == q_byte {
7797            positions.push(i);
7798        }
7799    }
7800    if positions.len() < 2 {
7801        return None;
7802    }
7803    let mut open_idx: Option<usize> = None;
7804    let mut close_idx: Option<usize> = None;
7805    for pair in positions.chunks(2) {
7806        if pair.len() < 2 {
7807            break;
7808        }
7809        if col >= pair[0] && col <= pair[1] {
7810            open_idx = Some(pair[0]);
7811            close_idx = Some(pair[1]);
7812            break;
7813        }
7814        if col < pair[0] {
7815            open_idx = Some(pair[0]);
7816            close_idx = Some(pair[1]);
7817            break;
7818        }
7819    }
7820    let open = open_idx?;
7821    let close = close_idx?;
7822    // End columns are *exclusive* — one past the last character to act on.
7823    if inner {
7824        if close <= open + 1 {
7825            return None;
7826        }
7827        Some(((row, open + 1), (row, close)))
7828    } else {
7829        // `da<q>` — "around" includes the surrounding whitespace on one
7830        // side: trailing whitespace if any exists after the closing quote;
7831        // otherwise leading whitespace before the opening quote. This
7832        // matches vim's `:help text-objects` behaviour and avoids leaving
7833        // a double-space when the quoted span sits mid-sentence.
7834        let after_close = close + 1; // byte index after closing quote
7835        if after_close < bytes.len() && bytes[after_close].is_ascii_whitespace() {
7836            // Eat trailing whitespace run.
7837            let mut end = after_close;
7838            while end < bytes.len() && bytes[end].is_ascii_whitespace() {
7839                end += 1;
7840            }
7841            Some(((row, open), (row, end)))
7842        } else if open > 0 && bytes[open - 1].is_ascii_whitespace() {
7843            // Eat leading whitespace run.
7844            let mut start = open;
7845            while start > 0 && bytes[start - 1].is_ascii_whitespace() {
7846                start -= 1;
7847            }
7848            Some(((row, start), (row, close + 1)))
7849        } else {
7850            Some(((row, open), (row, close + 1)))
7851        }
7852    }
7853}
7854
7855fn bracket_text_object<H: crate::types::Host>(
7856    ed: &Editor<hjkl_buffer::Buffer, H>,
7857    open: char,
7858    inner: bool,
7859    count: usize,
7860) -> Option<(Pos, Pos, RangeKind)> {
7861    let close = match open {
7862        '(' => ')',
7863        '[' => ']',
7864        '{' => '}',
7865        '<' => '>',
7866        _ => return None,
7867    };
7868    let (row, col) = ed.cursor();
7869    let lines = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
7870    let lines = lines.as_slice();
7871    // If the cursor sits ON the closing bracket, vim anchors the pair to that
7872    // bracket: the close is at the cursor and the open is found by scanning
7873    // backward from just before it. Without this, `find_open_bracket` counts
7874    // the cursor's own close, increments depth, and skips past its matching
7875    // open — making `di}`/`di{`-on-`}` a silent no-op.
7876    let cursor_char = lines.get(row).and_then(|l| l.chars().nth(col));
7877    let (open_pos, close_pos) = if cursor_char == Some(close) {
7878        let open_pos = if col > 0 {
7879            find_open_bracket(lines, row, col - 1, open, close)
7880        } else if row > 0 {
7881            let pr = row - 1;
7882            let pc = lines[pr].chars().count().saturating_sub(1);
7883            find_open_bracket(lines, pr, pc, open, close)
7884        } else {
7885            None
7886        }?;
7887        (open_pos, (row, col))
7888    } else {
7889        // Walk backward from cursor to find unbalanced opening. When the
7890        // cursor isn't inside any pair, fall back to scanning forward for
7891        // the next opening bracket (targets.vim-style: `ci(` works when
7892        // cursor is before the `(` on the same line or below).
7893        let open_pos = find_open_bracket(lines, row, col, open, close)
7894            .or_else(|| find_next_open(lines, row, col, open))?;
7895        let close_pos = find_close_bracket(lines, open_pos.0, open_pos.1 + 1, open, close)?;
7896        (open_pos, close_pos)
7897    };
7898    // Count: `2i{` / `2a{` target the Nth enclosing pair. Expand outward from
7899    // the innermost pair, re-anchoring to each enclosing bracket in turn. Stop
7900    // early (and use the outermost found) if there aren't `count` levels.
7901    let (open_pos, close_pos) = {
7902        let (mut op, mut cp) = (open_pos, close_pos);
7903        for _ in 1..count.max(1) {
7904            let outer = if op.1 > 0 {
7905                find_open_bracket(lines, op.0, op.1 - 1, open, close)
7906            } else if op.0 > 0 {
7907                let pr = op.0 - 1;
7908                let pc = lines[pr].chars().count().saturating_sub(1);
7909                find_open_bracket(lines, pr, pc, open, close)
7910            } else {
7911                None
7912            };
7913            let Some(oo) = outer else { break };
7914            let Some(oc) = find_close_bracket(lines, oo.0, oo.1 + 1, open, close) else {
7915                break;
7916            };
7917            op = oo;
7918            cp = oc;
7919        }
7920        (op, cp)
7921    };
7922    // End positions are *exclusive*.
7923    if inner {
7924        // The inner region is the raw charwise span from just after `{` to just
7925        // before `}`. Returned as Exclusive: the VISUAL path uses it directly
7926        // (so `vi{` is charwise — `vi{d` → "{}"), while the OPERATOR path
7927        // (`di{`/`ci{`) applies vim's exclusive-motion adjustment in
7928        // `apply_op_with_text_object` to collapse a contentful multi-line block
7929        // to bare braces ("{\n}") or promote a clean one to linewise.
7930        // Inner start = position just after `{`. When `{` is the last char on
7931        // its line, the inner region begins at the start of the next line (so
7932        // the exclusive-motion adjustment can promote to linewise). `advance_pos`
7933        // stops at end-of-line, so wrap explicitly here.
7934        let open_line_len = lines[open_pos.0].chars().count();
7935        let inner_start = if open_pos.1 + 1 >= open_line_len && open_pos.0 + 1 < lines.len() {
7936            (open_pos.0 + 1, 0)
7937        } else {
7938            advance_pos(lines, open_pos)
7939        };
7940        // Empty inner (`{}` / `( )` degenerate) → empty range at the inner
7941        // start. `di{` then no-ops; `ci{` inserts at that point.
7942        if inner_start.0 > close_pos.0
7943            || (inner_start.0 == close_pos.0 && inner_start.1 >= close_pos.1)
7944        {
7945            return Some((inner_start, inner_start, RangeKind::Exclusive));
7946        }
7947        // Whitespace-only multi-line inner: vim's `di{` is a no-op and `ci{`
7948        // inserts at the inner start without deleting the whitespace. Model as
7949        // an empty range at the inner start. Detected when every char strictly
7950        // between the braces (excluding newlines) is a space/tab, and there is
7951        // at least one — an inner of only newlines (empty lines) does NOT count
7952        // and falls through to the normal collapse.
7953        if close_pos.0 > open_pos.0 {
7954            let mut saw_ws = false;
7955            let mut saw_other = false;
7956            for r in inner_start.0..=close_pos.0 {
7957                let line: Vec<char> = lines
7958                    .get(r)
7959                    .map(|l| l.chars().collect())
7960                    .unwrap_or_default();
7961                let from = if r == inner_start.0 { inner_start.1 } else { 0 };
7962                let to = if r == close_pos.0 {
7963                    close_pos.1
7964                } else {
7965                    line.len()
7966                };
7967                for &c in line
7968                    .iter()
7969                    .take(to.min(line.len()))
7970                    .skip(from.min(line.len()))
7971                {
7972                    if c == ' ' || c == '\t' {
7973                        saw_ws = true;
7974                    } else {
7975                        saw_other = true;
7976                    }
7977                }
7978            }
7979            if saw_ws && !saw_other {
7980                return Some((inner_start, inner_start, RangeKind::Exclusive));
7981            }
7982        }
7983        Some((inner_start, close_pos, RangeKind::Exclusive))
7984    } else {
7985        Some((
7986            open_pos,
7987            advance_pos(lines, close_pos),
7988            RangeKind::Exclusive,
7989        ))
7990    }
7991}
7992
7993fn find_open_bracket(
7994    lines: &[String],
7995    row: usize,
7996    col: usize,
7997    open: char,
7998    close: char,
7999) -> Option<(usize, usize)> {
8000    let mut depth: i32 = 0;
8001    let mut r = row;
8002    let mut c = col as isize;
8003    loop {
8004        let cur = &lines[r];
8005        let chars: Vec<char> = cur.chars().collect();
8006        // Clamp `c` to the line length: callers may seed `col` past
8007        // EOL on virtual-cursor lines (e.g., insert mode after `o`)
8008        // so direct indexing would panic on empty / short lines.
8009        if (c as usize) >= chars.len() {
8010            c = chars.len() as isize - 1;
8011        }
8012        while c >= 0 {
8013            let ch = chars[c as usize];
8014            if ch == close {
8015                depth += 1;
8016            } else if ch == open {
8017                if depth == 0 {
8018                    return Some((r, c as usize));
8019                }
8020                depth -= 1;
8021            }
8022            c -= 1;
8023        }
8024        if r == 0 {
8025            return None;
8026        }
8027        r -= 1;
8028        c = lines[r].chars().count() as isize - 1;
8029    }
8030}
8031
8032fn find_close_bracket(
8033    lines: &[String],
8034    row: usize,
8035    start_col: usize,
8036    open: char,
8037    close: char,
8038) -> Option<(usize, usize)> {
8039    let mut depth: i32 = 0;
8040    let mut r = row;
8041    let mut c = start_col;
8042    loop {
8043        let cur = &lines[r];
8044        let chars: Vec<char> = cur.chars().collect();
8045        while c < chars.len() {
8046            let ch = chars[c];
8047            if ch == open {
8048                depth += 1;
8049            } else if ch == close {
8050                if depth == 0 {
8051                    return Some((r, c));
8052                }
8053                depth -= 1;
8054            }
8055            c += 1;
8056        }
8057        if r + 1 >= lines.len() {
8058            return None;
8059        }
8060        r += 1;
8061        c = 0;
8062    }
8063}
8064
8065/// Forward scan from `(row, col)` for the next occurrence of `open`.
8066/// Multi-line. Used by bracket text objects to support targets.vim-style
8067/// "search forward when not currently inside a pair" behaviour.
8068fn find_next_open(lines: &[String], row: usize, col: usize, open: char) -> Option<(usize, usize)> {
8069    let mut r = row;
8070    let mut c = col;
8071    while r < lines.len() {
8072        let chars: Vec<char> = lines[r].chars().collect();
8073        while c < chars.len() {
8074            if chars[c] == open {
8075                return Some((r, c));
8076            }
8077            c += 1;
8078        }
8079        r += 1;
8080        c = 0;
8081    }
8082    None
8083}
8084
8085fn advance_pos(lines: &[String], pos: (usize, usize)) -> (usize, usize) {
8086    let (r, c) = pos;
8087    let line_len = lines[r].chars().count();
8088    if c < line_len {
8089        (r, c + 1)
8090    } else if r + 1 < lines.len() {
8091        (r + 1, 0)
8092    } else {
8093        pos
8094    }
8095}
8096
8097fn paragraph_text_object<H: crate::types::Host>(
8098    ed: &Editor<hjkl_buffer::Buffer, H>,
8099    inner: bool,
8100) -> Option<((usize, usize), (usize, usize))> {
8101    let (row, _) = ed.cursor();
8102    let rope = crate::types::Query::rope(&ed.buffer);
8103    let n_lines = rope.len_lines();
8104    if n_lines == 0 {
8105        return None;
8106    }
8107    // A paragraph is a run of non-blank lines.
8108    let is_blank = |r: usize| -> bool {
8109        if r >= n_lines {
8110            return true;
8111        }
8112        rope_line_to_str(&rope, r).trim().is_empty()
8113    };
8114    if is_blank(row) {
8115        return None;
8116    }
8117    let mut top = row;
8118    while top > 0 && !is_blank(top - 1) {
8119        top -= 1;
8120    }
8121    let mut bot = row;
8122    while bot + 1 < n_lines && !is_blank(bot + 1) {
8123        bot += 1;
8124    }
8125    // For `ap`, include one trailing blank line if present.
8126    if !inner && bot + 1 < n_lines && is_blank(bot + 1) {
8127        bot += 1;
8128    }
8129    let end_col = rope_line_to_str(&rope, bot).chars().count();
8130    Some(((top, 0), (bot, end_col)))
8131}
8132
8133// ─── Individual commands ───────────────────────────────────────────────────
8134
8135/// Read the text in a vim-shaped range without mutating. Used by
8136/// `Operator::Yank` so we can pipe the same range translation as
8137/// [`cut_vim_range`] but skip the delete + inverse extraction.
8138fn read_vim_range<H: crate::types::Host>(
8139    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8140    start: (usize, usize),
8141    end: (usize, usize),
8142    kind: RangeKind,
8143) -> String {
8144    let (top, bot) = order(start, end);
8145    ed.sync_buffer_content_from_textarea();
8146    let rope = crate::types::Query::rope(&ed.buffer);
8147    let n_lines = rope.len_lines();
8148    match kind {
8149        RangeKind::Linewise => {
8150            let lo = top.0;
8151            let hi = bot.0.min(n_lines.saturating_sub(1));
8152            let mut text = rope_row_range_str(&rope, lo, hi);
8153            text.push('\n');
8154            text
8155        }
8156        RangeKind::Inclusive | RangeKind::Exclusive => {
8157            let inclusive = matches!(kind, RangeKind::Inclusive);
8158            // Walk row-by-row collecting chars in `[top, end_exclusive)`.
8159            let mut out = String::new();
8160            for row in top.0..=bot.0 {
8161                if row >= n_lines {
8162                    break;
8163                }
8164                let line = rope_line_to_str(&rope, row);
8165                let lo = if row == top.0 { top.1 } else { 0 };
8166                let hi_unclamped = if row == bot.0 {
8167                    if inclusive { bot.1 + 1 } else { bot.1 }
8168                } else {
8169                    line.chars().count() + 1
8170                };
8171                let row_chars: Vec<char> = line.chars().collect();
8172                let hi = hi_unclamped.min(row_chars.len());
8173                if lo < hi {
8174                    out.push_str(&row_chars[lo..hi].iter().collect::<String>());
8175                }
8176                if row < bot.0 {
8177                    out.push('\n');
8178                }
8179            }
8180            out
8181        }
8182    }
8183}
8184
8185/// Cut a vim-shaped range through the Buffer edit funnel and return
8186/// the deleted text. Translates vim's `RangeKind`
8187/// (Linewise/Inclusive/Exclusive) into the buffer's
8188/// `hjkl_buffer::MotionKind` (Line/Char) and applies the right end-
8189/// position adjustment so inclusive motions actually include the bot
8190/// cell. Pushes the cut text into the clipboard via `record_yank_to_host`
8191/// and the textarea yank buffer (still observed by `p`/`P` until the paste
8192/// path is ported), and updates `yank_linewise` for linewise cuts.
8193fn cut_vim_range<H: crate::types::Host>(
8194    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8195    start: (usize, usize),
8196    end: (usize, usize),
8197    kind: RangeKind,
8198) -> String {
8199    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
8200    let (top, bot) = order(start, end);
8201    ed.sync_buffer_content_from_textarea();
8202    let (buf_start, buf_end, buf_kind) = match kind {
8203        RangeKind::Linewise => (
8204            Position::new(top.0, 0),
8205            Position::new(bot.0, 0),
8206            BufKind::Line,
8207        ),
8208        RangeKind::Inclusive => {
8209            let line_chars = buf_line_chars(&ed.buffer, bot.0);
8210            // Advance one cell past `bot` so the buffer's exclusive
8211            // `cut_chars` actually drops the inclusive endpoint. Wrap
8212            // to the next row when bot already sits on the last char.
8213            let next = if bot.1 < line_chars {
8214                Position::new(bot.0, bot.1 + 1)
8215            } else if bot.0 + 1 < buf_row_count(&ed.buffer) {
8216                Position::new(bot.0 + 1, 0)
8217            } else {
8218                Position::new(bot.0, line_chars)
8219            };
8220            (Position::new(top.0, top.1), next, BufKind::Char)
8221        }
8222        RangeKind::Exclusive => (
8223            Position::new(top.0, top.1),
8224            Position::new(bot.0, bot.1),
8225            BufKind::Char,
8226        ),
8227    };
8228    let inverse = ed.mutate_edit(Edit::DeleteRange {
8229        start: buf_start,
8230        end: buf_end,
8231        kind: buf_kind,
8232    });
8233    let text = match inverse {
8234        Edit::InsertStr { text, .. } => text,
8235        _ => String::new(),
8236    };
8237    if !text.is_empty() {
8238        ed.record_yank_to_host(text.clone());
8239        ed.record_delete(text.clone(), matches!(kind, RangeKind::Linewise));
8240    }
8241    ed.push_buffer_cursor_to_textarea();
8242    text
8243}
8244
8245/// `D` / `C` — delete from cursor to end of line through the edit
8246/// funnel. Pushes the deleted text to the clipboard via `record_yank_to_host`
8247/// and the textarea's yank buffer (still observed by `p`/`P` until the paste
8248/// path is ported). Cursor lands at the deletion start so the caller
8249/// can decide whether to step it left (`D`) or open insert mode (`C`).
8250fn delete_to_eol<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
8251    use hjkl_buffer::{Edit, MotionKind, Position};
8252    ed.sync_buffer_content_from_textarea();
8253    let cursor = buf_cursor_pos(&ed.buffer);
8254    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
8255    if cursor.col >= line_chars {
8256        return;
8257    }
8258    let inverse = ed.mutate_edit(Edit::DeleteRange {
8259        start: cursor,
8260        end: Position::new(cursor.row, line_chars),
8261        kind: MotionKind::Char,
8262    });
8263    if let Edit::InsertStr { text, .. } = inverse
8264        && !text.is_empty()
8265    {
8266        ed.record_yank_to_host(text.clone());
8267        ed.vim.yank_linewise = false;
8268        ed.set_yank(text);
8269    }
8270    buf_set_cursor_pos(&mut ed.buffer, cursor);
8271    ed.push_buffer_cursor_to_textarea();
8272}
8273
8274fn do_char_delete<H: crate::types::Host>(
8275    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8276    forward: bool,
8277    count: usize,
8278) {
8279    use hjkl_buffer::{Edit, MotionKind, Position};
8280    ed.push_undo();
8281    ed.sync_buffer_content_from_textarea();
8282    // Collect deleted chars so we can write them to the unnamed register
8283    // (vim's `x`/`X` populate `"` so that `xp` round-trips the char).
8284    let mut deleted = String::new();
8285    for _ in 0..count {
8286        let cursor = buf_cursor_pos(&ed.buffer);
8287        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
8288        if forward {
8289            // `x` — delete the char under the cursor. Vim no-ops on
8290            // an empty line; the buffer would drop a row otherwise.
8291            if cursor.col >= line_chars {
8292                continue;
8293            }
8294            let inverse = ed.mutate_edit(Edit::DeleteRange {
8295                start: cursor,
8296                end: Position::new(cursor.row, cursor.col + 1),
8297                kind: MotionKind::Char,
8298            });
8299            if let Edit::InsertStr { text, .. } = inverse {
8300                deleted.push_str(&text);
8301            }
8302        } else {
8303            // `X` — delete the char before the cursor.
8304            if cursor.col == 0 {
8305                continue;
8306            }
8307            let inverse = ed.mutate_edit(Edit::DeleteRange {
8308                start: Position::new(cursor.row, cursor.col - 1),
8309                end: cursor,
8310                kind: MotionKind::Char,
8311            });
8312            if let Edit::InsertStr { text, .. } = inverse {
8313                // X deletes backwards; prepend so the register text
8314                // matches reading order (first deleted char first).
8315                deleted = text + &deleted;
8316            }
8317        }
8318    }
8319    if !deleted.is_empty() {
8320        ed.record_yank_to_host(deleted.clone());
8321        ed.record_delete(deleted, false);
8322    }
8323    ed.push_buffer_cursor_to_textarea();
8324}
8325
8326/// Vim `Ctrl-a` / `Ctrl-x` — find the next number at or after the cursor on the
8327/// current line, add `delta`, leave the cursor on the last digit of the result.
8328/// Recognises `0x`/`0X` hex literals (incremented in hex, width preserved) as
8329/// well as signed decimals. No-op if the line has no number to the right.
8330pub(crate) fn adjust_number<H: crate::types::Host>(
8331    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8332    delta: i64,
8333) -> bool {
8334    use hjkl_buffer::{Edit, MotionKind, Position};
8335    ed.sync_buffer_content_from_textarea();
8336    let cursor = buf_cursor_pos(&ed.buffer);
8337    let row = cursor.row;
8338    let chars: Vec<char> = match buf_line(&ed.buffer, row) {
8339        Some(l) => l.chars().collect(),
8340        None => return false,
8341    };
8342    let len = chars.len();
8343
8344    // Scan from the cursor for the start of the leftmost number — a `0x`/`0X`
8345    // hex literal takes priority over a bare decimal at the same position.
8346    let is_hex_prefix = |i: usize| {
8347        chars[i] == '0'
8348            && i + 1 < len
8349            && matches!(chars[i + 1], 'x' | 'X')
8350            && chars.get(i + 2).is_some_and(|c| c.is_ascii_hexdigit())
8351    };
8352    let mut i = cursor.col;
8353    let mut hex = false;
8354    loop {
8355        if i >= len {
8356            return false;
8357        }
8358        if is_hex_prefix(i) {
8359            hex = true;
8360            break;
8361        }
8362        if chars[i].is_ascii_digit() {
8363            break;
8364        }
8365        i += 1;
8366    }
8367
8368    let (span_start, span_end, new_s) = if hex {
8369        // `0x` + hex digits. Increment the value, preserve the digit width.
8370        let digits_start = i + 2;
8371        let mut digits_end = digits_start;
8372        while digits_end < len && chars[digits_end].is_ascii_hexdigit() {
8373            digits_end += 1;
8374        }
8375        let hexs: String = chars[digits_start..digits_end].iter().collect();
8376        let Ok(n) = u64::from_str_radix(&hexs, 16) else {
8377            return false;
8378        };
8379        let new_val = (n as i128 + delta as i128).max(0) as u64;
8380        let width = digits_end - digits_start;
8381        let prefix: String = chars[i..digits_start].iter().collect();
8382        (i, digits_end, format!("{prefix}{new_val:0width$x}"))
8383    } else {
8384        // Signed decimal.
8385        let digit_start = i;
8386        let span_start = if digit_start > 0 && chars[digit_start - 1] == '-' {
8387            digit_start - 1
8388        } else {
8389            digit_start
8390        };
8391        let mut span_end = digit_start;
8392        while span_end < len && chars[span_end].is_ascii_digit() {
8393            span_end += 1;
8394        }
8395        let s: String = chars[span_start..span_end].iter().collect();
8396        let Ok(n) = s.parse::<i64>() else {
8397            return false;
8398        };
8399        (span_start, span_end, n.saturating_add(delta).to_string())
8400    };
8401
8402    ed.push_undo();
8403    let span_start_pos = Position::new(row, span_start);
8404    let span_end_pos = Position::new(row, span_end);
8405    ed.mutate_edit(Edit::DeleteRange {
8406        start: span_start_pos,
8407        end: span_end_pos,
8408        kind: MotionKind::Char,
8409    });
8410    ed.mutate_edit(Edit::InsertStr {
8411        at: span_start_pos,
8412        text: new_s.clone(),
8413    });
8414    let new_len = new_s.chars().count();
8415    buf_set_cursor_rc(&mut ed.buffer, row, span_start + new_len.saturating_sub(1));
8416    ed.push_buffer_cursor_to_textarea();
8417    true
8418}
8419
8420pub(crate) fn replace_char<H: crate::types::Host>(
8421    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8422    ch: char,
8423    count: usize,
8424) {
8425    use hjkl_buffer::{Edit, MotionKind, Position};
8426    ed.push_undo();
8427    ed.sync_buffer_content_from_textarea();
8428    for _ in 0..count {
8429        let cursor = buf_cursor_pos(&ed.buffer);
8430        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
8431        if cursor.col >= line_chars {
8432            break;
8433        }
8434        ed.mutate_edit(Edit::DeleteRange {
8435            start: cursor,
8436            end: Position::new(cursor.row, cursor.col + 1),
8437            kind: MotionKind::Char,
8438        });
8439        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
8440    }
8441    // Vim leaves the cursor on the last replaced char.
8442    crate::motions::move_left(&mut ed.buffer, 1);
8443    ed.push_buffer_cursor_to_textarea();
8444}
8445
8446fn toggle_case_at_cursor<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
8447    use hjkl_buffer::{Edit, MotionKind, Position};
8448    ed.sync_buffer_content_from_textarea();
8449    let cursor = buf_cursor_pos(&ed.buffer);
8450    let Some(c) = buf_line(&ed.buffer, cursor.row).and_then(|l| l.chars().nth(cursor.col)) else {
8451        return;
8452    };
8453    let toggled = if c.is_uppercase() {
8454        c.to_lowercase().next().unwrap_or(c)
8455    } else {
8456        c.to_uppercase().next().unwrap_or(c)
8457    };
8458    ed.mutate_edit(Edit::DeleteRange {
8459        start: cursor,
8460        end: Position::new(cursor.row, cursor.col + 1),
8461        kind: MotionKind::Char,
8462    });
8463    ed.mutate_edit(Edit::InsertChar {
8464        at: cursor,
8465        ch: toggled,
8466    });
8467}
8468
8469fn join_line<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
8470    use hjkl_buffer::{Edit, Position};
8471    ed.sync_buffer_content_from_textarea();
8472    let row = buf_cursor_pos(&ed.buffer).row;
8473    if row + 1 >= buf_row_count(&ed.buffer) {
8474        return;
8475    }
8476    let cur_line = buf_line(&ed.buffer, row).unwrap_or_default();
8477    let next_raw = buf_line(&ed.buffer, row + 1).unwrap_or_default();
8478    let next_trimmed = next_raw.trim_start();
8479    let cur_chars = cur_line.chars().count();
8480    let next_chars = next_raw.chars().count();
8481    // `J` inserts a single space iff both sides are non-empty after
8482    // stripping the next line's leading whitespace.
8483    let separator = if !cur_line.is_empty() && !next_trimmed.is_empty() {
8484        " "
8485    } else {
8486        ""
8487    };
8488    let joined = format!("{cur_line}{separator}{next_trimmed}");
8489    ed.mutate_edit(Edit::Replace {
8490        start: Position::new(row, 0),
8491        end: Position::new(row + 1, next_chars),
8492        with: joined,
8493    });
8494    // Vim parks the cursor on the inserted space — or at the join
8495    // point when no space went in (which is the same column either
8496    // way, since the space sits exactly at `cur_chars`).
8497    buf_set_cursor_rc(&mut ed.buffer, row, cur_chars);
8498    ed.push_buffer_cursor_to_textarea();
8499}
8500
8501/// `gJ` — join the next line onto the current one without inserting a
8502/// separating space or stripping leading whitespace.
8503fn join_line_raw<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
8504    use hjkl_buffer::Edit;
8505    ed.sync_buffer_content_from_textarea();
8506    let row = buf_cursor_pos(&ed.buffer).row;
8507    if row + 1 >= buf_row_count(&ed.buffer) {
8508        return;
8509    }
8510    let join_col = buf_line_chars(&ed.buffer, row);
8511    ed.mutate_edit(Edit::JoinLines {
8512        row,
8513        count: 1,
8514        with_space: false,
8515    });
8516    // Vim leaves the cursor at the join point (end of original line).
8517    buf_set_cursor_rc(&mut ed.buffer, row, join_col);
8518    ed.push_buffer_cursor_to_textarea();
8519}
8520
8521/// Visual-mode `J` (`with_space = true`) / `gJ` (`with_space = false`) — join
8522/// every line spanned by the selection into one. A single-line selection joins
8523/// the current line with the one below (matching normal-mode `J`).
8524pub(crate) fn visual_join<H: crate::types::Host>(
8525    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8526    with_space: bool,
8527) {
8528    let cursor_row = buf_cursor_pos(&ed.buffer).row;
8529    let (top, bot) = match ed.vim.mode {
8530        Mode::VisualLine => (
8531            cursor_row.min(ed.vim.visual_line_anchor),
8532            cursor_row.max(ed.vim.visual_line_anchor),
8533        ),
8534        Mode::VisualBlock => {
8535            let a = ed.vim.block_anchor.0;
8536            (a.min(cursor_row), a.max(cursor_row))
8537        }
8538        Mode::Visual => {
8539            let a = ed.vim.visual_anchor.0;
8540            (a.min(cursor_row), a.max(cursor_row))
8541        }
8542        _ => return,
8543    };
8544    // N selected lines → N-1 joins; a single line still does one join (with the
8545    // line below) like normal-mode `J`.
8546    let joins = (bot - top).max(1);
8547    ed.push_undo();
8548    buf_set_cursor_rc(&mut ed.buffer, top, 0);
8549    ed.push_buffer_cursor_to_textarea();
8550    for _ in 0..joins {
8551        if with_space {
8552            join_line(ed);
8553        } else {
8554            join_line_raw(ed);
8555        }
8556    }
8557    ed.vim.mode = Mode::Normal;
8558    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
8559}
8560
8561/// `[count]%` — go to the line at `count` percent of the file (vim: line
8562/// `(count * line_count + 99) / 100`), cursor on the first non-blank.
8563pub(crate) fn goto_percent<H: crate::types::Host>(
8564    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8565    count: usize,
8566) {
8567    let rows = buf_row_count(&ed.buffer);
8568    if rows == 0 {
8569        return;
8570    }
8571    // Exclude the phantom trailing empty line (a file ending in `\n` is N lines
8572    // in vim, not N+1) so the percentage matches nvim.
8573    let total = if rows >= 2
8574        && buf_line(&ed.buffer, rows - 1)
8575            .map(|s| s.is_empty())
8576            .unwrap_or(false)
8577    {
8578        rows - 1
8579    } else {
8580        rows
8581    };
8582    // 1-based target line, clamped to the buffer (vim: ceil(count*lines/100)).
8583    let line = (count * total).div_ceil(100).clamp(1, total);
8584    let pre = ed.cursor();
8585    ed.jump_cursor(line - 1, 0);
8586    move_first_non_whitespace(ed);
8587    ed.sticky_col = Some(ed.cursor().1);
8588    if ed.cursor() != pre {
8589        ed.push_jump(pre);
8590    }
8591}
8592
8593/// Indent width of a leading-whitespace prefix, counting a `\t` as advancing
8594/// to the next `tabstop` boundary and a space as one column.
8595fn indent_width(s: &str, tabstop: usize) -> usize {
8596    let ts = tabstop.max(1);
8597    let mut w = 0usize;
8598    for c in s.chars() {
8599        match c {
8600            ' ' => w += 1,
8601            '\t' => w += ts - (w % ts),
8602            _ => break,
8603        }
8604    }
8605    w
8606}
8607
8608/// Build a leading-whitespace string of `width` columns honoring `expandtab`
8609/// (spaces) vs `noexpandtab` (tabs for full `tabstop` runs, spaces remainder).
8610fn build_indent(width: usize, settings: &crate::editor::Settings) -> String {
8611    if settings.expandtab {
8612        return " ".repeat(width);
8613    }
8614    let ts = settings.tabstop.max(1);
8615    let tabs = width / ts;
8616    let spaces = width % ts;
8617    format!("{}{}", "\t".repeat(tabs), " ".repeat(spaces))
8618}
8619
8620/// `]p` / `[p` reindent: shift every line of `text` so the FIRST line's indent
8621/// matches `target_width` columns; later lines keep their relative offset.
8622fn reindent_block(text: &str, target_width: usize, settings: &crate::editor::Settings) -> String {
8623    let ts = settings.tabstop.max(1);
8624    let lines: Vec<&str> = text.split('\n').collect();
8625    let first_width = lines.first().map(|l| indent_width(l, ts)).unwrap_or(0);
8626    let delta = target_width as isize - first_width as isize;
8627    lines
8628        .iter()
8629        .map(|line| {
8630            let trimmed = line.trim_start_matches([' ', '\t']);
8631            if trimmed.is_empty() {
8632                // Preserve blank lines as truly empty (vim does not indent them).
8633                return String::new();
8634            }
8635            let old_w = indent_width(line, ts) as isize;
8636            let new_w = (old_w + delta).max(0) as usize;
8637            format!("{}{}", build_indent(new_w, settings), trimmed)
8638        })
8639        .collect::<Vec<_>>()
8640        .join("\n")
8641}
8642
8643fn do_paste<H: crate::types::Host>(
8644    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8645    before: bool,
8646    count: usize,
8647    cursor_after: bool,
8648    reindent: bool,
8649) {
8650    use hjkl_buffer::{Edit, Position};
8651    ed.push_undo();
8652    // Resolve the source register: `"reg` prefix (consumed) or the
8653    // unnamed register otherwise. Read text + linewise from the
8654    // selected slot rather than the global `vim.yank_linewise` so
8655    // pasting from `"0` after a delete still uses the yank's layout.
8656    let selector = ed.vim.pending_register.take();
8657    let (yank, linewise) = {
8658        let regs = ed.registers();
8659        match selector.and_then(|c| regs.read(c)) {
8660            Some(slot) => (slot.text.clone(), slot.linewise),
8661            // Read both fields from the unnamed slot rather than mixing the
8662            // slot's text with `vim.yank_linewise`. The cached vim flag is
8663            // per-editor, so a register imported from another editor (e.g.
8664            // cross-buffer yank/paste) carried the wrong linewise without
8665            // this — pasting a linewise yank inserted at the char cursor.
8666            None => (regs.unnamed.text.clone(), regs.unnamed.linewise),
8667        }
8668    };
8669    // Vim `:h '[` / `:h ']`: after paste `[` = first inserted char of
8670    // the final paste, `]` = last inserted char of the final paste.
8671    // We track (lo, hi) across iterations; the last value wins.
8672    let mut paste_mark: Option<((usize, usize), (usize, usize))> = None;
8673    // Capture the cursor row before any paste iterations. Vim's
8674    // linewise `[count]p` lands the cursor on the FIRST pasted line
8675    // (original_row + 1), not on the last iteration's paste row.
8676    // Without this snapshot the per-iteration cursor advancement leaves
8677    // the cursor at `original_row + count` instead.
8678    let original_row_for_linewise_after = if linewise && !before {
8679        // Fold-aware: `p` on a closed fold pastes after the fold, so the first
8680        // pasted line is `fold_end + 1`, not `cursor_row + 1`.
8681        let r = buf_cursor_pos(&ed.buffer).row;
8682        let (_, fold_end) = expand_linewise_over_closed_folds(&ed.buffer, r, r);
8683        Some(fold_end)
8684    } else {
8685        None
8686    };
8687    for _ in 0..count {
8688        ed.sync_buffer_content_from_textarea();
8689        let yank = yank.clone();
8690        if yank.is_empty() {
8691            continue;
8692        }
8693        if linewise {
8694            // Linewise paste: insert payload as fresh row(s) above
8695            // (`P`) or below (`p`) the cursor's row. Cursor lands on
8696            // the first non-blank of the first pasted line.
8697            let mut text = yank.trim_matches('\n').to_string();
8698            let row = buf_cursor_pos(&ed.buffer).row;
8699            // `]p` / `[p` — reindent the pasted block to the current line.
8700            if reindent {
8701                let cur_line = buf_line(&ed.buffer, row).unwrap_or_default();
8702                let target_w = indent_width(&cur_line, ed.settings.tabstop.max(1));
8703                text = reindent_block(&text, target_w, &ed.settings);
8704            }
8705            // Fold-aware: linewise paste lands relative to the whole CLOSED
8706            // fold, not just the cursor line — `p` after the fold's last row,
8707            // `P` before its first row (vim behaviour). No fold → unchanged.
8708            let (fold_start, fold_end) = expand_linewise_over_closed_folds(&ed.buffer, row, row);
8709            let target_row = if before {
8710                ed.mutate_edit(Edit::InsertStr {
8711                    at: Position::new(fold_start, 0),
8712                    text: format!("{text}\n"),
8713                });
8714                fold_start
8715            } else {
8716                let line_chars = buf_line_chars(&ed.buffer, fold_end);
8717                ed.mutate_edit(Edit::InsertStr {
8718                    at: Position::new(fold_end, line_chars),
8719                    text: format!("\n{text}"),
8720                });
8721                fold_end + 1
8722            };
8723            buf_set_cursor_rc(&mut ed.buffer, target_row, 0);
8724            crate::motions::move_first_non_blank(&mut ed.buffer);
8725            ed.push_buffer_cursor_to_textarea();
8726            // Linewise: `[` = (target_row, 0), `]` = (bot_row, last_col).
8727            let payload_lines = text.lines().count().max(1);
8728            let bot_row = target_row + payload_lines - 1;
8729            let bot_last_col = buf_line_chars(&ed.buffer, bot_row).saturating_sub(1);
8730            paste_mark = Some(((target_row, 0), (bot_row, bot_last_col)));
8731        } else {
8732            // Charwise paste. `P` inserts at cursor (shifting cell
8733            // right); `p` inserts after cursor (advance one cell
8734            // first, clamped to the end of the line).
8735            let cursor = buf_cursor_pos(&ed.buffer);
8736            let at = if before {
8737                cursor
8738            } else {
8739                let line_chars = buf_line_chars(&ed.buffer, cursor.row);
8740                Position::new(cursor.row, (cursor.col + 1).min(line_chars))
8741            };
8742            ed.mutate_edit(Edit::InsertStr {
8743                at,
8744                text: yank.clone(),
8745            });
8746            // Vim parks the cursor on the last char of the pasted text
8747            // (do_insert_str leaves it one past the end). `gp` instead
8748            // leaves the cursor just AFTER the pasted text, so skip the
8749            // step-back there.
8750            if !cursor_after && ed.cursor().1 > 0 {
8751                crate::motions::move_left(&mut ed.buffer, 1);
8752                ed.push_buffer_cursor_to_textarea();
8753            }
8754            // Charwise: `[` = insert start, `]` = last pasted char.
8755            let lo = (at.row, at.col);
8756            let hi = if cursor_after {
8757                let c = ed.cursor();
8758                (c.0, c.1.saturating_sub(1))
8759            } else {
8760                ed.cursor()
8761            };
8762            paste_mark = Some((lo, hi));
8763        }
8764    }
8765    if let Some((lo, hi)) = paste_mark {
8766        ed.set_mark('[', lo);
8767        ed.set_mark(']', hi);
8768    }
8769    // `gp` / `gP` linewise: cursor lands on the line just AFTER the pasted
8770    // block (the `]` mark's row + 1), at column 0, clamped to the last row.
8771    if cursor_after && linewise {
8772        if let Some((_, (bot_row, _))) = paste_mark {
8773            let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
8774            let target = (bot_row + 1).min(last_row);
8775            buf_set_cursor_rc(&mut ed.buffer, target, 0);
8776            ed.push_buffer_cursor_to_textarea();
8777        }
8778    } else if let Some(orig_row) = original_row_for_linewise_after {
8779        // Linewise `p` (after) with count: cursor lands on the FIRST pasted
8780        // line (original_row + 1) — vim parity. The per-iteration loop
8781        // moves cursor to each paste's target_row, so without this reset
8782        // `5p` would land at original_row + 5 instead of original_row + 1.
8783        let first_target = orig_row.saturating_add(1);
8784        buf_set_cursor_rc(&mut ed.buffer, first_target, 0);
8785        crate::motions::move_first_non_blank(&mut ed.buffer);
8786        ed.push_buffer_cursor_to_textarea();
8787    }
8788    // Any paste re-anchors the sticky column to the new cursor position.
8789    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
8790}
8791
8792/// Visual-mode `p` / `P` — replace the active selection with the register.
8793/// With `p` the deleted selection lands in the unnamed register (vim's swap);
8794/// with `P` (`before = true`) the source register is preserved so it can be
8795/// pasted over multiple selections in turn.
8796pub(crate) fn visual_paste<H: crate::types::Host>(
8797    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8798    before: bool,
8799) {
8800    use hjkl_buffer::{Edit, Position};
8801    ed.sync_buffer_content_from_textarea();
8802
8803    // Resolve the source register (selector or unnamed) BEFORE the delete
8804    // overwrites the unnamed register with the cut selection.
8805    let selector = ed.vim.pending_register.take();
8806    let (reg_text, reg_linewise) = {
8807        let regs = ed.registers();
8808        match selector.and_then(|c| regs.read(c)) {
8809            Some(slot) => (slot.text.clone(), slot.linewise),
8810            None => (regs.unnamed.text.clone(), regs.unnamed.linewise),
8811        }
8812    };
8813    // For `P`, snapshot the unnamed register so we can restore it afterwards.
8814    let saved_unnamed = before.then(|| ed.registers().unnamed.clone());
8815
8816    let mode = ed.vim.mode;
8817    ed.push_undo();
8818
8819    match mode {
8820        Mode::VisualLine => {
8821            let cursor_row = buf_cursor_pos(&ed.buffer).row;
8822            let top = cursor_row.min(ed.vim.visual_line_anchor);
8823            let bot = cursor_row.max(ed.vim.visual_line_anchor);
8824            // Delete the selected lines into the unnamed register.
8825            cut_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
8826            // Insert the register as fresh line(s) where the selection was.
8827            let text = reg_text.trim_matches('\n').to_string();
8828            let line_count = buf_row_count(&ed.buffer);
8829            if top >= line_count {
8830                // Selection reached the end of the buffer: append below the
8831                // (new) last line.
8832                let last = line_count.saturating_sub(1);
8833                let lc = buf_line_chars(&ed.buffer, last);
8834                ed.mutate_edit(Edit::InsertStr {
8835                    at: Position::new(last, lc),
8836                    text: format!("\n{text}"),
8837                });
8838                buf_set_cursor_rc(&mut ed.buffer, last + 1, 0);
8839            } else {
8840                ed.mutate_edit(Edit::InsertStr {
8841                    at: Position::new(top, 0),
8842                    text: format!("{text}\n"),
8843                });
8844                buf_set_cursor_rc(&mut ed.buffer, top, 0);
8845            }
8846            crate::motions::move_first_non_blank(&mut ed.buffer);
8847            ed.push_buffer_cursor_to_textarea();
8848        }
8849        Mode::Visual | Mode::VisualBlock => {
8850            let anchor = if mode == Mode::VisualBlock {
8851                ed.vim.block_anchor
8852            } else {
8853                ed.vim.visual_anchor
8854            };
8855            let cursor = ed.cursor();
8856            let (top, bot) = order(anchor, cursor);
8857            // Delete the selection into the unnamed register.
8858            cut_vim_range(ed, top, bot, RangeKind::Inclusive);
8859            // Insert the register text where the selection started.
8860            if reg_linewise {
8861                // Linewise register into a charwise hole: open a line below.
8862                let text = reg_text.trim_matches('\n').to_string();
8863                let lc = buf_line_chars(&ed.buffer, top.0);
8864                ed.mutate_edit(Edit::InsertStr {
8865                    at: Position::new(top.0, lc),
8866                    text: format!("\n{text}"),
8867                });
8868                buf_set_cursor_rc(&mut ed.buffer, top.0 + 1, 0);
8869                crate::motions::move_first_non_blank(&mut ed.buffer);
8870            } else {
8871                ed.mutate_edit(Edit::InsertStr {
8872                    at: Position::new(top.0, top.1),
8873                    text: reg_text.clone(),
8874                });
8875                // Park the cursor on the last char of the inserted text.
8876                let inserted_len = reg_text.chars().count();
8877                let last_col = top.1 + inserted_len.saturating_sub(1);
8878                buf_set_cursor_rc(&mut ed.buffer, top.0, last_col);
8879            }
8880            ed.push_buffer_cursor_to_textarea();
8881        }
8882        _ => {}
8883    }
8884
8885    // `P` preserves the source register; restore the snapshot.
8886    if let Some(slot) = saved_unnamed {
8887        ed.registers_mut().unnamed = slot;
8888    }
8889    ed.vim.mode = Mode::Normal;
8890    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
8891}
8892
8893/// Visual-mode `<C-a>` / `<C-x>` and `g<C-a>` / `g<C-x>`. Adds `delta` to the
8894/// first number on each selected line. When `sequential` is true the increment
8895/// grows by `delta` for each successive number found (vim's `g<C-a>`): the
8896/// first gets `delta`, the second `2*delta`, and so on.
8897pub(crate) fn adjust_number_visual<H: crate::types::Host>(
8898    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8899    delta: i64,
8900    sequential: bool,
8901) {
8902    use hjkl_buffer::{Edit, MotionKind, Position};
8903    ed.sync_buffer_content_from_textarea();
8904    let mode = ed.vim.mode;
8905    let cursor = buf_cursor_pos(&ed.buffer);
8906
8907    // Resolve the row range + the per-row start column to scan from.
8908    let (top, bot, mut scan_col_first, block_left) = match mode {
8909        Mode::VisualLine => {
8910            let t = cursor.row.min(ed.vim.visual_line_anchor);
8911            let b = cursor.row.max(ed.vim.visual_line_anchor);
8912            (t, b, 0usize, None)
8913        }
8914        Mode::Visual => {
8915            let (a, c) = order(ed.vim.visual_anchor, (cursor.row, cursor.col));
8916            (a.0, c.0, a.1, None)
8917        }
8918        Mode::VisualBlock => {
8919            let (a, c) = order(ed.vim.block_anchor, (cursor.row, cursor.col));
8920            let left = a.1.min(c.1);
8921            (a.0, c.0, left, Some(left))
8922        }
8923        _ => return,
8924    };
8925
8926    ed.push_undo();
8927    let mut found_count: i64 = 0;
8928    for row in top..=bot {
8929        let start_col = match block_left {
8930            Some(left) => left,
8931            None => {
8932                // First row of a charwise selection starts at the anchor/cursor
8933                // column; subsequent rows start at column 0.
8934                let c = if row == top { scan_col_first } else { 0 };
8935                scan_col_first = 0;
8936                c
8937            }
8938        };
8939        let chars: Vec<char> = match buf_line(&ed.buffer, row) {
8940            Some(l) => l.chars().collect(),
8941            None => continue,
8942        };
8943        let Some(digit_start) =
8944            (start_col.min(chars.len())..chars.len()).find(|&i| chars[i].is_ascii_digit())
8945        else {
8946            continue;
8947        };
8948        let span_start = if digit_start > 0 && chars[digit_start - 1] == '-' {
8949            digit_start - 1
8950        } else {
8951            digit_start
8952        };
8953        let mut span_end = digit_start;
8954        while span_end < chars.len() && chars[span_end].is_ascii_digit() {
8955            span_end += 1;
8956        }
8957        let s: String = chars[span_start..span_end].iter().collect();
8958        let Ok(n) = s.parse::<i64>() else {
8959            continue;
8960        };
8961        found_count += 1;
8962        let this_delta = if sequential {
8963            delta.saturating_mul(found_count)
8964        } else {
8965            delta
8966        };
8967        let new_s = n.saturating_add(this_delta).to_string();
8968        let span_start_pos = Position::new(row, span_start);
8969        let span_end_pos = Position::new(row, span_end);
8970        ed.mutate_edit(Edit::DeleteRange {
8971            start: span_start_pos,
8972            end: span_end_pos,
8973            kind: MotionKind::Char,
8974        });
8975        ed.mutate_edit(Edit::InsertStr {
8976            at: span_start_pos,
8977            text: new_s,
8978        });
8979    }
8980    // Vim leaves the cursor at the start of the selection.
8981    buf_set_cursor_rc(&mut ed.buffer, top, block_left.unwrap_or(0));
8982    ed.push_buffer_cursor_to_textarea();
8983    ed.vim.mode = Mode::Normal;
8984    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
8985}
8986
8987pub(crate) fn do_undo<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
8988    if let Some(entry) = ed.buffer.pop_undo_entry() {
8989        let (cur_rope, cur_cursor) = ed.snapshot();
8990        ed.buffer.push_redo_entry(hjkl_buffer::UndoEntry {
8991            rope: cur_rope,
8992            cursor: cur_cursor,
8993            timestamp: entry.timestamp,
8994        });
8995        ed.restore_rope(entry.rope, entry.cursor);
8996    }
8997    ed.vim.mode = Mode::Normal;
8998    // The restored cursor came from a snapshot taken in insert mode
8999    // (before the insert started) and may be past the last valid
9000    // normal-mode column. Clamp it now, same as Esc-from-insert does.
9001    clamp_cursor_to_normal_mode(ed);
9002}
9003
9004pub(crate) fn do_redo<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
9005    if let Some(entry) = ed.buffer.pop_redo_entry() {
9006        let (cur_rope, cur_cursor) = ed.snapshot();
9007        let before = cur_rope.clone();
9008        ed.buffer.push_undo_entry(hjkl_buffer::UndoEntry {
9009            rope: cur_rope,
9010            cursor: cur_cursor,
9011            timestamp: entry.timestamp,
9012        });
9013        ed.cap_undo();
9014        ed.restore_rope(entry.rope, entry.cursor);
9015        // vim parks the cursor at the START of the reapplied change, not the
9016        // end-of-insert position stored in the redo snapshot. Recompute it from
9017        // the first character that differs between the pre- and post-redo text.
9018        let after = crate::types::Query::rope(&ed.buffer);
9019        if let Some((row, col)) = first_diff_pos(&before, &after) {
9020            buf_set_cursor_rc(&mut ed.buffer, row, col);
9021            ed.push_buffer_cursor_to_textarea();
9022        }
9023    }
9024    ed.vim.mode = Mode::Normal;
9025    clamp_cursor_to_normal_mode(ed);
9026}
9027
9028/// First `(row, col)` where two ropes differ, or `None` if identical. Used to
9029/// place the cursor at the start of a redone change (vim parity).
9030fn first_diff_pos(a: &ropey::Rope, b: &ropey::Rope) -> Option<(usize, usize)> {
9031    let rows = a.len_lines().max(b.len_lines());
9032    for r in 0..rows {
9033        let la = if r < a.len_lines() {
9034            hjkl_buffer::rope_line_str(a, r)
9035        } else {
9036            String::new()
9037        };
9038        let lb = if r < b.len_lines() {
9039            hjkl_buffer::rope_line_str(b, r)
9040        } else {
9041            String::new()
9042        };
9043        if la != lb {
9044            let col = la
9045                .chars()
9046                .zip(lb.chars())
9047                .take_while(|(x, y)| x == y)
9048                .count();
9049            return Some((r, col));
9050        }
9051    }
9052    None
9053}
9054
9055// ─── Dot repeat ────────────────────────────────────────────────────────────
9056
9057/// Replay-side helper: insert `text` at the cursor through the
9058/// edit funnel, then leave insert mode (the original change ended
9059/// with Esc, so the dot-repeat must end the same way — including
9060/// the cursor step-back vim does on Esc-from-insert).
9061fn replay_insert_and_finish<H: crate::types::Host>(
9062    ed: &mut Editor<hjkl_buffer::Buffer, H>,
9063    text: &str,
9064) {
9065    use hjkl_buffer::{Edit, Position};
9066    let cursor = ed.cursor();
9067    ed.mutate_edit(Edit::InsertStr {
9068        at: Position::new(cursor.0, cursor.1),
9069        text: text.to_string(),
9070    });
9071    if ed.vim.insert_session.take().is_some() {
9072        if ed.cursor().1 > 0 {
9073            crate::motions::move_left(&mut ed.buffer, 1);
9074            ed.push_buffer_cursor_to_textarea();
9075        }
9076        ed.vim.mode = Mode::Normal;
9077    }
9078}
9079
9080pub(crate) fn replay_last_change<H: crate::types::Host>(
9081    ed: &mut Editor<hjkl_buffer::Buffer, H>,
9082    outer_count: usize,
9083) {
9084    let Some(change) = ed.vim.last_change.clone() else {
9085        return;
9086    };
9087    ed.vim.replaying = true;
9088    let scale = if outer_count > 0 { outer_count } else { 1 };
9089    match change {
9090        LastChange::OpMotion {
9091            op,
9092            motion,
9093            count,
9094            inserted,
9095        } => {
9096            let total = count.max(1) * scale;
9097            apply_op_with_motion(ed, op, &motion, total);
9098            if let Some(text) = inserted {
9099                replay_insert_and_finish(ed, &text);
9100            }
9101        }
9102        LastChange::OpTextObj {
9103            op,
9104            obj,
9105            inner,
9106            inserted,
9107        } => {
9108            // Dot-repeat replays the text object at count 1 (the original
9109            // count is not retained in `LastChange::OpTextObj`).
9110            apply_op_with_text_object(ed, op, obj, inner, 1);
9111            if let Some(text) = inserted {
9112                replay_insert_and_finish(ed, &text);
9113            }
9114        }
9115        LastChange::LineOp {
9116            op,
9117            count,
9118            inserted,
9119        } => {
9120            let total = count.max(1) * scale;
9121            execute_line_op(ed, op, total);
9122            if let Some(text) = inserted {
9123                replay_insert_and_finish(ed, &text);
9124            }
9125        }
9126        LastChange::CharDel { forward, count } => {
9127            do_char_delete(ed, forward, count * scale);
9128        }
9129        LastChange::ReplaceChar { ch, count } => {
9130            replace_char(ed, ch, count * scale);
9131        }
9132        LastChange::ToggleCase { count } => {
9133            for _ in 0..count * scale {
9134                ed.push_undo();
9135                toggle_case_at_cursor(ed);
9136            }
9137        }
9138        LastChange::JoinLine { count } => {
9139            for _ in 0..count * scale {
9140                ed.push_undo();
9141                join_line(ed);
9142            }
9143        }
9144        LastChange::Paste {
9145            before,
9146            count,
9147            cursor_after,
9148            reindent,
9149        } => {
9150            do_paste(ed, before, count * scale, cursor_after, reindent);
9151        }
9152        LastChange::GnOp {
9153            op,
9154            forward,
9155            inserted,
9156        } => {
9157            gn_operate(ed, Some(op), forward, 1);
9158            if let Some(text) = inserted {
9159                replay_insert_and_finish(ed, &text);
9160            }
9161        }
9162        LastChange::ReplaceMode { text } => {
9163            use hjkl_buffer::{Edit, MotionKind, Position};
9164            ed.push_undo();
9165            for ch in text.chars() {
9166                let cursor = buf_cursor_pos(&ed.buffer);
9167                let line_chars = buf_line_chars(&ed.buffer, cursor.row);
9168                if cursor.col < line_chars {
9169                    // Overtype the char under the cursor.
9170                    ed.mutate_edit(Edit::DeleteRange {
9171                        start: cursor,
9172                        end: Position::new(cursor.row, cursor.col + 1),
9173                        kind: MotionKind::Char,
9174                    });
9175                }
9176                ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
9177                buf_set_cursor_rc(&mut ed.buffer, cursor.row, cursor.col + 1);
9178            }
9179            // Esc step-back onto the last overtyped char.
9180            if ed.cursor().1 > 0 {
9181                crate::motions::move_left(&mut ed.buffer, 1);
9182            }
9183            ed.push_buffer_cursor_to_textarea();
9184        }
9185        LastChange::DeleteToEol { inserted } => {
9186            use hjkl_buffer::{Edit, Position};
9187            ed.push_undo();
9188            delete_to_eol(ed);
9189            if let Some(text) = inserted {
9190                let cursor = ed.cursor();
9191                ed.mutate_edit(Edit::InsertStr {
9192                    at: Position::new(cursor.0, cursor.1),
9193                    text,
9194                });
9195            }
9196        }
9197        LastChange::OpenLine { above, inserted } => {
9198            use hjkl_buffer::{Edit, Position};
9199            ed.push_undo();
9200            ed.sync_buffer_content_from_textarea();
9201            let row = buf_cursor_pos(&ed.buffer).row;
9202            if above {
9203                ed.mutate_edit(Edit::InsertStr {
9204                    at: Position::new(row, 0),
9205                    text: "\n".to_string(),
9206                });
9207                let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
9208                crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
9209            } else {
9210                let line_chars = buf_line_chars(&ed.buffer, row);
9211                ed.mutate_edit(Edit::InsertStr {
9212                    at: Position::new(row, line_chars),
9213                    text: "\n".to_string(),
9214                });
9215            }
9216            ed.push_buffer_cursor_to_textarea();
9217            let cursor = ed.cursor();
9218            ed.mutate_edit(Edit::InsertStr {
9219                at: Position::new(cursor.0, cursor.1),
9220                text: inserted,
9221            });
9222        }
9223        LastChange::InsertAt {
9224            entry,
9225            inserted,
9226            count,
9227        } => {
9228            use hjkl_buffer::{Edit, Position};
9229            ed.push_undo();
9230            match entry {
9231                InsertEntry::I => {}
9232                InsertEntry::ShiftI => move_first_non_whitespace(ed),
9233                InsertEntry::A => {
9234                    crate::motions::move_right_to_end(&mut ed.buffer, 1);
9235                    ed.push_buffer_cursor_to_textarea();
9236                }
9237                InsertEntry::ShiftA => {
9238                    crate::motions::move_line_end(&mut ed.buffer);
9239                    crate::motions::move_right_to_end(&mut ed.buffer, 1);
9240                    ed.push_buffer_cursor_to_textarea();
9241                }
9242            }
9243            for _ in 0..count.max(1) {
9244                let cursor = ed.cursor();
9245                ed.mutate_edit(Edit::InsertStr {
9246                    at: Position::new(cursor.0, cursor.1),
9247                    text: inserted.clone(),
9248                });
9249            }
9250        }
9251    }
9252    ed.vim.replaying = false;
9253}
9254
9255// ─── Extracting inserted text for replay ───────────────────────────────────
9256
9257/// The substring of `after` that differs from `before` (first-diff to
9258/// last-diff). Unlike [`extract_inserted`] this works for equal-length or
9259/// shorter results, so it captures `R` overstrike text for dot-repeat.
9260fn changed_run(before: &str, after: &str) -> String {
9261    let a: Vec<char> = before.chars().collect();
9262    let b: Vec<char> = after.chars().collect();
9263    let prefix = a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count();
9264    let max_suffix = a.len().min(b.len()) - prefix;
9265    let suffix = a
9266        .iter()
9267        .rev()
9268        .zip(b.iter().rev())
9269        .take(max_suffix)
9270        .take_while(|(x, y)| x == y)
9271        .count();
9272    b[prefix..b.len() - suffix].iter().collect()
9273}
9274
9275fn extract_inserted(before: &str, after: &str) -> String {
9276    let before_chars: Vec<char> = before.chars().collect();
9277    let after_chars: Vec<char> = after.chars().collect();
9278    if after_chars.len() <= before_chars.len() {
9279        return String::new();
9280    }
9281    let prefix = before_chars
9282        .iter()
9283        .zip(after_chars.iter())
9284        .take_while(|(a, b)| a == b)
9285        .count();
9286    let max_suffix = before_chars.len() - prefix;
9287    let suffix = before_chars
9288        .iter()
9289        .rev()
9290        .zip(after_chars.iter().rev())
9291        .take(max_suffix)
9292        .take_while(|(a, b)| a == b)
9293        .count();
9294    after_chars[prefix..after_chars.len() - suffix]
9295        .iter()
9296        .collect()
9297}
9298
9299// ─── Tests ────────────────────────────────────────────────────────────────
9300
9301#[cfg(test)]
9302mod comment_continuation_tests {
9303    use super::*;
9304    use crate::{DefaultHost, Editor, Options};
9305    use hjkl_buffer::Buffer;
9306
9307    fn make_editor_with_lang(lang: &str, content: &str) -> Editor<Buffer, DefaultHost> {
9308        let buf = Buffer::from_str(content);
9309        let host = DefaultHost::new();
9310        let opts = Options {
9311            filetype: lang.to_string(),
9312            formatoptions: "ro".to_string(),
9313            ..Options::default()
9314        };
9315        Editor::new(buf, host, opts)
9316    }
9317
9318    #[test]
9319    fn detect_rust_doc_comment() {
9320        let result = detect_comment_on_line("rust", "/// foo bar");
9321        assert!(result.is_some());
9322        let (indent, prefix) = result.unwrap();
9323        assert_eq!(indent, "");
9324        assert_eq!(prefix, "/// ");
9325    }
9326
9327    #[test]
9328    fn detect_rust_inner_doc_comment() {
9329        let result = detect_comment_on_line("rust", "//! crate docs");
9330        assert!(result.is_some());
9331        let (_, prefix) = result.unwrap();
9332        assert_eq!(prefix, "//! ");
9333    }
9334
9335    #[test]
9336    fn detect_rust_plain_comment() {
9337        let result = detect_comment_on_line("rust", "// normal comment");
9338        assert!(result.is_some());
9339        let (_, prefix) = result.unwrap();
9340        assert_eq!(prefix, "// ");
9341    }
9342
9343    #[test]
9344    fn detect_indented_comment() {
9345        let result = detect_comment_on_line("rust", "    // indented");
9346        assert!(result.is_some());
9347        let (indent, prefix) = result.unwrap();
9348        assert_eq!(indent, "    ");
9349        assert_eq!(prefix, "// ");
9350    }
9351
9352    #[test]
9353    fn detect_python_hash() {
9354        let result = detect_comment_on_line("python", "# comment");
9355        assert!(result.is_some());
9356        let (_, prefix) = result.unwrap();
9357        assert_eq!(prefix, "# ");
9358    }
9359
9360    #[test]
9361    fn detect_lua_double_dash() {
9362        let result = detect_comment_on_line("lua", "-- a lua comment");
9363        assert!(result.is_some());
9364        let (_, prefix) = result.unwrap();
9365        assert_eq!(prefix, "-- ");
9366    }
9367
9368    #[test]
9369    fn detect_non_comment_is_none() {
9370        assert!(detect_comment_on_line("rust", "let x = 1;").is_none());
9371        assert!(detect_comment_on_line("python", "x = 1").is_none());
9372    }
9373
9374    #[test]
9375    fn detect_bare_double_slash_still_matches() {
9376        // A line that is exactly `//` with nothing after.
9377        assert!(detect_comment_on_line("rust", "//").is_some());
9378    }
9379
9380    #[test]
9381    fn rust_doc_before_plain() {
9382        // `///` must match before `//`.
9383        let result = detect_comment_on_line("rust", "/// outer doc");
9384        let (_, prefix) = result.unwrap();
9385        assert_eq!(prefix, "/// ", "/// must match before //");
9386    }
9387
9388    #[test]
9389    fn continue_comment_returns_prefix_for_comment_row() {
9390        let ed = make_editor_with_lang("rust", "/// hello\n");
9391        let cont = continue_comment(&ed.buffer, &ed.settings, 0);
9392        assert_eq!(cont, Some("/// ".to_string()));
9393    }
9394
9395    #[test]
9396    fn continue_comment_returns_none_for_non_comment() {
9397        let ed = make_editor_with_lang("rust", "let x = 1;\n");
9398        let cont = continue_comment(&ed.buffer, &ed.settings, 0);
9399        assert!(cont.is_none());
9400    }
9401
9402    #[test]
9403    fn continue_comment_returns_none_when_filetype_empty() {
9404        let buf = Buffer::from_str("// hello\n");
9405        let host = DefaultHost::new();
9406        // filetype defaults to "" in Options::default().
9407        let ed = Editor::new(buf, host, Options::default());
9408        let cont = continue_comment(&ed.buffer, &ed.settings, 0);
9409        assert!(cont.is_none());
9410    }
9411}
9412
9413#[cfg(test)]
9414mod comment_toggle_tests {
9415    use super::*;
9416    use crate::{DefaultHost, Editor, Options};
9417    use hjkl_buffer::Buffer;
9418
9419    fn make_rust_editor(content: &str) -> Editor<Buffer, DefaultHost> {
9420        let buf = Buffer::from_str(content);
9421        let host = DefaultHost::new();
9422        let opts = Options {
9423            filetype: "rust".to_string(),
9424            ..Options::default()
9425        };
9426        Editor::new(buf, host, opts)
9427    }
9428
9429    fn line(ed: &Editor<Buffer, DefaultHost>, row: usize) -> String {
9430        buf_line(&ed.buffer, row).unwrap_or_default()
9431    }
9432
9433    // ── gcc: toggle comment on current line ──────────────────────────────────
9434
9435    #[test]
9436    fn gcc_comments_rust_line() {
9437        let mut ed = make_rust_editor("let x = 1;");
9438        ed.toggle_comment_range(0, 0);
9439        assert_eq!(line(&ed, 0), "// let x = 1;");
9440    }
9441
9442    #[test]
9443    fn gcc_uncomments_rust_line() {
9444        let mut ed = make_rust_editor("// let x = 1;");
9445        ed.toggle_comment_range(0, 0);
9446        assert_eq!(line(&ed, 0), "let x = 1;");
9447    }
9448
9449    #[test]
9450    fn gcc_indent_preserving() {
9451        // Marker inserted after leading whitespace, not at column 0.
9452        let mut ed = make_rust_editor("    let x = 1;");
9453        ed.toggle_comment_range(0, 0);
9454        assert_eq!(line(&ed, 0), "    // let x = 1;");
9455    }
9456
9457    #[test]
9458    fn gcc_indent_preserving_uncomment() {
9459        let mut ed = make_rust_editor("    // let x = 1;");
9460        ed.toggle_comment_range(0, 0);
9461        assert_eq!(line(&ed, 0), "    let x = 1;");
9462    }
9463
9464    // ── Multi-line toggle ────────────────────────────────────────────────────
9465
9466    #[test]
9467    fn toggle_multi_line_all_uncommented() {
9468        let content = "let a = 1;\nlet b = 2;\nlet c = 3;";
9469        let mut ed = make_rust_editor(content);
9470        ed.toggle_comment_range(0, 2);
9471        assert_eq!(line(&ed, 0), "// let a = 1;");
9472        assert_eq!(line(&ed, 1), "// let b = 2;");
9473        assert_eq!(line(&ed, 2), "// let c = 3;");
9474    }
9475
9476    #[test]
9477    fn toggle_multi_line_all_commented() {
9478        let content = "// let a = 1;\n// let b = 2;\n// let c = 3;";
9479        let mut ed = make_rust_editor(content);
9480        ed.toggle_comment_range(0, 2);
9481        assert_eq!(line(&ed, 0), "let a = 1;");
9482        assert_eq!(line(&ed, 1), "let b = 2;");
9483        assert_eq!(line(&ed, 2), "let c = 3;");
9484    }
9485
9486    // ── Mixed state → all gets commented (vim-commentary parity) ────────────
9487
9488    #[test]
9489    fn toggle_mixed_state_comments_all() {
9490        // 3 uncommented + 2 commented → all 5 get commented.
9491        let content = "let a = 1;\n// let b = 2;\nlet c = 3;\n// let d = 4;\nlet e = 5;";
9492        let mut ed = make_rust_editor(content);
9493        ed.toggle_comment_range(0, 4);
9494        for r in 0..5 {
9495            assert!(
9496                line(&ed, r).trim_start().starts_with("//"),
9497                "row {r} not commented: {:?}",
9498                line(&ed, r)
9499            );
9500        }
9501    }
9502
9503    // ── Blank lines skipped ──────────────────────────────────────────────────
9504
9505    #[test]
9506    fn blank_lines_not_commented() {
9507        let content = "let a = 1;\n\nlet b = 2;";
9508        let mut ed = make_rust_editor(content);
9509        ed.toggle_comment_range(0, 2);
9510        assert_eq!(line(&ed, 0), "// let a = 1;");
9511        assert_eq!(line(&ed, 1), ""); // blank — untouched
9512        assert_eq!(line(&ed, 2), "// let b = 2;");
9513    }
9514
9515    // ── Python hash comments ─────────────────────────────────────────────────
9516
9517    #[test]
9518    fn python_comment_toggle() {
9519        let buf = Buffer::from_str("x = 1\ny = 2");
9520        let host = DefaultHost::new();
9521        let opts = Options {
9522            filetype: "python".to_string(),
9523            ..Options::default()
9524        };
9525        let mut ed = Editor::new(buf, host, opts);
9526        ed.toggle_comment_range(0, 1);
9527        assert_eq!(line(&ed, 0), "# x = 1");
9528        assert_eq!(line(&ed, 1), "# y = 2");
9529        // Toggle back.
9530        ed.toggle_comment_range(0, 1);
9531        assert_eq!(line(&ed, 0), "x = 1");
9532        assert_eq!(line(&ed, 1), "y = 2");
9533    }
9534
9535    // ── commentstring override ───────────────────────────────────────────────
9536
9537    #[test]
9538    fn commentstring_override_via_setting() {
9539        let buf = Buffer::from_str("hello world");
9540        let host = DefaultHost::new();
9541        let opts = Options {
9542            filetype: "rust".to_string(),
9543            ..Options::default()
9544        };
9545        let mut ed = Editor::new(buf, host, opts);
9546        // Override with a custom marker.
9547        ed.settings_mut().commentstring = "# %s".to_string();
9548        ed.toggle_comment_range(0, 0);
9549        assert_eq!(line(&ed, 0), "# hello world");
9550    }
9551
9552    // ── Unknown language → no-op ─────────────────────────────────────────────
9553
9554    #[test]
9555    fn unknown_lang_no_op() {
9556        let buf = Buffer::from_str("hello");
9557        let host = DefaultHost::new();
9558        let opts = Options::default(); // filetype = ""
9559        let mut ed = Editor::new(buf, host, opts);
9560        ed.toggle_comment_range(0, 0);
9561        // Should be unchanged — no comment string for "".
9562        assert_eq!(line(&ed, 0), "hello");
9563    }
9564}
9565
9566// ─── g& tests ─────────────────────────────────────────────────────────────
9567
9568#[cfg(test)]
9569mod g_ampersand_tests {
9570    use super::*;
9571    use crate::{DefaultHost, Editor, Options};
9572    use hjkl_buffer::{Buffer, rope_line_str};
9573
9574    fn make_editor(content: &str) -> Editor<Buffer, DefaultHost> {
9575        let buf = Buffer::from_str(content);
9576        let host = DefaultHost::new();
9577        Editor::new(buf, host, Options::default())
9578    }
9579
9580    fn buf_line(ed: &Editor<Buffer, DefaultHost>, row: usize) -> String {
9581        let rope = ed.buffer().rope();
9582        rope_line_str(&rope, row).trim_end_matches('\n').to_string()
9583    }
9584
9585    /// `g&` repeats last `:s/foo/bar/` over every line (no /g flag → first
9586    /// match per line only).
9587    #[test]
9588    fn g_ampersand_repeats_last_substitute_on_whole_buffer() {
9589        let mut ed = make_editor("foo\nfoo bar foo\nbaz");
9590        // Simulate a prior `:s/foo/bar/` by setting last_substitute directly.
9591        let cmd = crate::substitute::parse_substitute("/foo/bar/").unwrap();
9592        ed.set_last_substitute(cmd);
9593        // Cursor on line 0 (to confirm g& operates on ALL lines, not just current).
9594        apply_after_g(&mut ed, '&', 1);
9595        assert_eq!(buf_line(&ed, 0), "bar");
9596        // No /g flag — only first match per line.
9597        assert_eq!(buf_line(&ed, 1), "bar bar foo");
9598        assert_eq!(buf_line(&ed, 2), "baz");
9599    }
9600
9601    /// `g&` with /g flag replaces all matches per line.
9602    #[test]
9603    fn g_ampersand_with_g_flag_replaces_all_per_line() {
9604        let mut ed = make_editor("foo foo\nfoo");
9605        let cmd = crate::substitute::parse_substitute("/foo/bar/g").unwrap();
9606        ed.set_last_substitute(cmd);
9607        apply_after_g(&mut ed, '&', 1);
9608        assert_eq!(buf_line(&ed, 0), "bar bar");
9609        assert_eq!(buf_line(&ed, 1), "bar");
9610    }
9611
9612    /// `g&` with no prior substitute is a no-op.
9613    #[test]
9614    fn g_ampersand_noop_when_no_prior_substitute() {
9615        let mut ed = make_editor("foo\nbar");
9616        // No last_substitute set — must not panic, must not change buffer.
9617        apply_after_g(&mut ed, '&', 1);
9618        assert_eq!(buf_line(&ed, 0), "foo");
9619        assert_eq!(buf_line(&ed, 1), "bar");
9620    }
9621}
9622
9623// ─── Sneak motion tests ───────────────────────────────────────────────────
9624
9625#[cfg(test)]
9626mod sneak_tests {
9627    use super::*;
9628    use crate::{DefaultHost, Editor, Options};
9629    use hjkl_buffer::Buffer;
9630
9631    fn make_editor(content: &str) -> Editor<Buffer, DefaultHost> {
9632        let buf = Buffer::from_str(content);
9633        let host = DefaultHost::new();
9634        Editor::new(buf, host, Options::default())
9635    }
9636
9637    /// `s ba` from [0,0] on "foo bar baz qux\n" → cursor at [0,4] (start of "ba" in "bar").
9638    #[test]
9639    fn sneak_forward_jumps_to_two_char_digraph() {
9640        let mut ed = make_editor("foo bar baz qux\n");
9641        ed.jump_cursor(0, 0);
9642        ed.sneak('b', 'a', true, 1);
9643        assert_eq!(ed.cursor(), (0, 4), "cursor should land on 'ba' in 'bar'");
9644    }
9645
9646    /// `S ba` from [0,12] on "foo bar baz qux\n" → cursor at [0,8] ("ba" in "baz").
9647    #[test]
9648    fn sneak_backward_jumps_to_prior_match() {
9649        let mut ed = make_editor("foo bar baz qux\n");
9650        ed.jump_cursor(0, 12);
9651        ed.sneak('b', 'a', false, 1);
9652        assert_eq!(
9653            ed.cursor(),
9654            (0, 8),
9655            "backward sneak should find 'ba' in 'baz'"
9656        );
9657    }
9658
9659    /// After sneak forward to "bar", `;` (sneak-repeat) jumps to next "ba" ("baz").
9660    #[test]
9661    fn sneak_repeat_semicolon_next_match() {
9662        let mut ed = make_editor("foo bar baz qux\n");
9663        ed.jump_cursor(0, 0);
9664        // First sneak: lands at [0,4]
9665        ed.sneak('b', 'a', true, 1);
9666        assert_eq!(ed.cursor(), (0, 4));
9667        // Repeat via execute_motion FindRepeat (which routes through sneak if last was sneak)
9668        execute_motion(&mut ed, Motion::FindRepeat { reverse: false }, 1);
9669        assert_eq!(ed.cursor(), (0, 8), "semicolon should jump to next 'ba'");
9670    }
9671
9672    /// After sneak forward from [0,0] to [0,4], `,` (reverse) — no prior "ba" → stays.
9673    #[test]
9674    fn sneak_repeat_comma_prev_match() {
9675        let mut ed = make_editor("foo bar baz qux\n");
9676        ed.jump_cursor(0, 0);
9677        ed.sneak('b', 'a', true, 1);
9678        assert_eq!(ed.cursor(), (0, 4));
9679        // Reverse repeat — no "ba" before col 4, so cursor must not move.
9680        let pre = ed.cursor();
9681        execute_motion(&mut ed, Motion::FindRepeat { reverse: true }, 1);
9682        assert_eq!(
9683            ed.cursor(),
9684            pre,
9685            "comma with no prior match should leave cursor unchanged"
9686        );
9687    }
9688
9689    /// `S ba` from [0,12] jumps backward.
9690    #[test]
9691    fn sneak_s_searches_backward() {
9692        let mut ed = make_editor("foo bar baz qux\n");
9693        ed.jump_cursor(0, 12);
9694        ed.sneak('b', 'a', false, 1);
9695        assert_eq!(ed.cursor(), (0, 8));
9696    }
9697
9698    /// `2s ba` from [0,0] jumps to 2nd "ba" occurrence.
9699    #[test]
9700    fn sneak_with_count_jumps_to_nth() {
9701        let mut ed = make_editor("foo bar baz qux\n");
9702        ed.jump_cursor(0, 0);
9703        ed.sneak('b', 'a', true, 2);
9704        assert_eq!(ed.cursor(), (0, 8), "count=2 should jump to 2nd 'ba'");
9705    }
9706
9707    /// `s xx` with no match — cursor stays put.
9708    #[test]
9709    fn sneak_no_match_cursor_stays() {
9710        let mut ed = make_editor("foo bar baz qux\n");
9711        ed.jump_cursor(0, 0);
9712        let pre = ed.cursor();
9713        ed.sneak('x', 'x', true, 1);
9714        assert_eq!(ed.cursor(), pre, "no match should leave cursor unchanged");
9715    }
9716
9717    /// `dsab` on "hello ab world\n" from [0,0] → deletes up to 'ab', leaving "ab world\n".
9718    #[test]
9719    fn operator_pending_dsab_deletes_to_digraph() {
9720        let mut ed = make_editor("hello ab world\n");
9721        ed.jump_cursor(0, 0);
9722        ed.apply_op_sneak(Operator::Delete, 'a', 'b', true, 1);
9723        // Buffer content after exclusive delete from [0,0] to [0,6] (start of "ab").
9724        let content = ed.content();
9725        assert!(
9726            content.starts_with("ab world"),
9727            "dsab should delete 'hello ' leaving 'ab world'; got: {content:?}"
9728        );
9729    }
9730
9731    /// Cross-line sneak: "foo\nbar baz\n", cursor [0,0], `s ba` → [1,0].
9732    #[test]
9733    fn sneak_cross_line_match() {
9734        let mut ed = make_editor("foo\nbar baz\n");
9735        ed.jump_cursor(0, 0);
9736        ed.sneak('b', 'a', true, 1);
9737        assert_eq!(ed.cursor(), (1, 0), "sneak should cross line boundary");
9738    }
9739
9740    /// `last_sneak` is updated after `sneak()` so `;`/`,` can repeat.
9741    #[test]
9742    fn sneak_updates_last_sneak_state() {
9743        let mut ed = make_editor("foo bar baz\n");
9744        ed.jump_cursor(0, 0);
9745        ed.sneak('b', 'a', true, 1);
9746        let ls = ed.last_sneak();
9747        assert_eq!(
9748            ls,
9749            Some((('b', 'a'), true)),
9750            "last_sneak should record the digraph and direction"
9751        );
9752    }
9753}
9754
9755// ─── [count]>> / [count]<< line-operator count tests ──────────────────────
9756//
9757// vim semantics (captured from `nvim --headless`, mirrored by the
9758// `tier2_indent_count` oracle corpus):
9759//   - `[count]op` operates on `count` lines from the cursor, clamped to the
9760//     buffer end.
9761//   - The implied `count_` motion moves `count - 1` lines down; on the last
9762//     line it can't move, so `[count>=2]>>` / `<<` is a complete no-op (E16).
9763#[cfg(test)]
9764mod indent_count_tests {
9765    use super::*;
9766    use crate::{DefaultHost, Editor, Options};
9767    use hjkl_buffer::Buffer;
9768
9769    fn make_editor(content: &str) -> Editor<Buffer, DefaultHost> {
9770        let buf = Buffer::from_str(content);
9771        let mut ed = Editor::new(buf, DefaultHost::new(), Options::default());
9772        ed.settings_mut().expandtab = true;
9773        ed.settings_mut().shiftwidth = 4;
9774        ed
9775    }
9776
9777    fn content(ed: &Editor<Buffer, DefaultHost>) -> String {
9778        (*ed.buffer().content_joined()).clone()
9779    }
9780
9781    #[test]
9782    fn count_indent_operates_on_n_lines() {
9783        let mut ed = make_editor("a\nb\nc\nd\ne\nf\n");
9784        ed.jump_cursor(0, 0);
9785        execute_line_op(&mut ed, Operator::Indent, 3);
9786        assert_eq!(content(&ed), "    a\n    b\n    c\nd\ne\nf\n");
9787    }
9788
9789    #[test]
9790    fn count_indent_clamps_to_buffer_end() {
9791        let mut ed = make_editor("a\nb\nc\nd\ne\nf\n");
9792        ed.jump_cursor(0, 0);
9793        execute_line_op(&mut ed, Operator::Indent, 10);
9794        assert_eq!(content(&ed), "    a\n    b\n    c\n    d\n    e\n    f\n");
9795    }
9796
9797    #[test]
9798    fn count_outdent_clamps_to_buffer_end() {
9799        let mut ed = make_editor("    a\n    b\n    c\n");
9800        ed.jump_cursor(0, 0);
9801        execute_line_op(&mut ed, Operator::Outdent, 10);
9802        assert_eq!(content(&ed), "a\nb\nc\n");
9803    }
9804
9805    #[test]
9806    fn count_indent_on_last_line_is_noop() {
9807        let mut ed = make_editor("a\nb\nc\n");
9808        ed.jump_cursor(2, 0); // last content line
9809        execute_line_op(&mut ed, Operator::Indent, 5);
9810        assert_eq!(
9811            content(&ed),
9812            "a\nb\nc\n",
9813            "5>> on last line must abort (E16)"
9814        );
9815    }
9816
9817    #[test]
9818    fn count_indent_on_single_line_is_noop() {
9819        let mut ed = make_editor("x\n");
9820        ed.jump_cursor(0, 0);
9821        execute_line_op(&mut ed, Operator::Indent, 5);
9822        assert_eq!(content(&ed), "x\n", "5>> on the only line must abort (E16)");
9823    }
9824
9825    #[test]
9826    fn count_outdent_on_last_line_is_noop() {
9827        let mut ed = make_editor("    a\n    b\n    c\n");
9828        ed.jump_cursor(2, 0);
9829        execute_line_op(&mut ed, Operator::Outdent, 5);
9830        assert_eq!(content(&ed), "    a\n    b\n    c\n");
9831    }
9832
9833    #[test]
9834    fn single_indent_on_last_line_still_works() {
9835        // count == 1 needs no motion, so `>>` on the last line indents it.
9836        let mut ed = make_editor("a\nb\nc\n");
9837        ed.jump_cursor(2, 0);
9838        execute_line_op(&mut ed, Operator::Indent, 1);
9839        assert_eq!(content(&ed), "a\nb\n    c\n");
9840    }
9841}
9842
9843// ── try_abbrev_expand unit tests ─────────────────────────────────────────────
9844
9845#[cfg(test)]
9846mod abbrev_tests {
9847    use super::{Abbrev, AbbrevKind, AbbrevTrigger, abbrev_kind, try_abbrev_expand};
9848    use AbbrevKind::{End, Full, NonKw};
9849
9850    const ISK: &str = "@,48-57,_,192-255"; // default iskeyword
9851
9852    fn make_abbrev(lhs: &str, rhs: &str) -> Abbrev {
9853        Abbrev {
9854            lhs: lhs.to_string(),
9855            rhs: rhs.to_string(),
9856            insert: true,
9857            cmdline: false,
9858            noremap: false,
9859        }
9860    }
9861
9862    fn expand(
9863        abbrevs: &[Abbrev],
9864        before: &str,
9865        mincol: usize,
9866        trig: AbbrevTrigger,
9867    ) -> Option<(usize, String)> {
9868        try_abbrev_expand(abbrevs, before, mincol, trig, ISK)
9869    }
9870
9871    // ── abbrev_type classification ────────────────────────────────────────────
9872
9873    #[test]
9874    fn fullid_all_keyword_chars() {
9875        assert_eq!(abbrev_kind("teh", ISK), Full);
9876        assert_eq!(abbrev_kind("abc123", ISK), Full);
9877        assert_eq!(abbrev_kind("_foo", ISK), Full);
9878    }
9879
9880    #[test]
9881    fn endid_ends_with_kw_has_nonkw() {
9882        assert_eq!(abbrev_kind("#i", ISK), End);
9883        assert_eq!(abbrev_kind("#include", ISK), End);
9884    }
9885
9886    #[test]
9887    fn nonid_ends_with_nonkw() {
9888        assert_eq!(abbrev_kind(";;", ISK), NonKw);
9889        assert_eq!(abbrev_kind("->", ISK), NonKw);
9890    }
9891
9892    // ── full-id expansion ─────────────────────────────────────────────────────
9893
9894    #[test]
9895    fn fullid_expands_on_space_trigger() {
9896        let abbrevs = [make_abbrev("teh", "the")];
9897        let r = expand(&abbrevs, "teh", 0, AbbrevTrigger::NonKeyword(' '));
9898        assert_eq!(r, Some((3, "the".to_string())));
9899    }
9900
9901    #[test]
9902    fn fullid_expands_on_esc_trigger() {
9903        let abbrevs = [make_abbrev("teh", "the")];
9904        let r = expand(&abbrevs, "teh", 0, AbbrevTrigger::Esc);
9905        assert_eq!(r, Some((3, "the".to_string())));
9906    }
9907
9908    #[test]
9909    fn fullid_expands_on_cr_trigger() {
9910        let abbrevs = [make_abbrev("teh", "the")];
9911        let r = expand(&abbrevs, "teh", 0, AbbrevTrigger::Cr);
9912        assert_eq!(r, Some((3, "the".to_string())));
9913    }
9914
9915    #[test]
9916    fn fullid_expands_on_ctrl_bracket() {
9917        let abbrevs = [make_abbrev("teh", "the")];
9918        let r = expand(&abbrevs, "teh", 0, AbbrevTrigger::CtrlBracket);
9919        assert_eq!(r, Some((3, "the".to_string())));
9920    }
9921
9922    #[test]
9923    fn fullid_does_not_expand_on_keyword_trigger() {
9924        // Typing a keyword char after "teh" would extend the word — no expand.
9925        let abbrevs = [make_abbrev("teh", "the")];
9926        let r = expand(&abbrevs, "teh", 0, AbbrevTrigger::NonKeyword('a'));
9927        // 'a' is keyword — should not trigger
9928        assert_eq!(r, None);
9929    }
9930
9931    #[test]
9932    fn fullid_no_expand_when_lhs_not_at_end() {
9933        let abbrevs = [make_abbrev("teh", "the")];
9934        // "ateh" — 'a' before is keyword, so skip.
9935        let r = expand(&abbrevs, "ateh", 0, AbbrevTrigger::NonKeyword(' '));
9936        assert_eq!(r, None);
9937    }
9938
9939    #[test]
9940    fn fullid_expands_after_nonkw_prefix() {
9941        let abbrevs = [make_abbrev("teh", "the")];
9942        // "!teh" — '!' before is non-keyword → expand.
9943        let r = expand(&abbrevs, "!teh", 0, AbbrevTrigger::NonKeyword(' '));
9944        assert_eq!(r, Some((3, "the".to_string())));
9945    }
9946
9947    #[test]
9948    fn fullid_single_char_no_expand_after_nonblank_nonkw() {
9949        let abbrevs = [make_abbrev("a", "b")];
9950        // "!a" — '!' is non-blank non-keyword before single-char lhs → no expand.
9951        let r = expand(&abbrevs, "!a", 0, AbbrevTrigger::NonKeyword(' '));
9952        assert_eq!(r, None);
9953    }
9954
9955    #[test]
9956    fn fullid_single_char_expands_after_space() {
9957        let abbrevs = [make_abbrev("a", "b")];
9958        // " a" — space before single-char lhs → expand.
9959        let r = expand(&abbrevs, " a", 0, AbbrevTrigger::NonKeyword(' '));
9960        assert_eq!(r, Some((1, "b".to_string())));
9961    }
9962
9963    // ── mincol: pre-existing text must not be consumed ────────────────────────
9964
9965    #[test]
9966    fn mincol_blocks_consuming_preexisting_text() {
9967        let abbrevs = [make_abbrev("teh", "the")];
9968        // "teh" is at cols 0..3, but insert started at col 3 → no match.
9969        let r = expand(&abbrevs, "teh", 3, AbbrevTrigger::NonKeyword(' '));
9970        assert_eq!(r, None);
9971    }
9972
9973    #[test]
9974    fn mincol_allows_match_starting_at_mincol() {
9975        let abbrevs = [make_abbrev("teh", "the")];
9976        // Existing text "!! " at 0..3, then user typed "teh" → mincol=3.
9977        // The char before the lhs is ' ' (non-keyword), so full-id expands.
9978        let r = expand(&abbrevs, "!! teh", 3, AbbrevTrigger::NonKeyword(' '));
9979        assert_eq!(r, Some((3, "the".to_string())));
9980    }
9981
9982    // ── end-id expansion ──────────────────────────────────────────────────────
9983
9984    #[test]
9985    fn endid_expands_on_space_trigger() {
9986        let abbrevs = [make_abbrev("#i", "#include")];
9987        let r = expand(&abbrevs, "#i", 0, AbbrevTrigger::NonKeyword(' '));
9988        assert_eq!(r, Some((2, "#include".to_string())));
9989    }
9990
9991    #[test]
9992    fn endid_expands_on_esc_trigger() {
9993        let abbrevs = [make_abbrev("#i", "#include")];
9994        let r = expand(&abbrevs, "#i", 0, AbbrevTrigger::Esc);
9995        assert_eq!(r, Some((2, "#include".to_string())));
9996    }
9997
9998    // ── non-id expansion ──────────────────────────────────────────────────────
9999
10000    #[test]
10001    fn nonid_expands_on_esc_trigger() {
10002        let abbrevs = [make_abbrev(";;", "std::endl;")];
10003        let r = expand(&abbrevs, ";;", 0, AbbrevTrigger::Esc);
10004        assert_eq!(r, Some((2, "std::endl;".to_string())));
10005    }
10006
10007    #[test]
10008    fn nonid_expands_on_cr_trigger() {
10009        let abbrevs = [make_abbrev(";;", "std::endl;")];
10010        let r = expand(&abbrevs, ";;", 0, AbbrevTrigger::Cr);
10011        assert_eq!(r, Some((2, "std::endl;".to_string())));
10012    }
10013
10014    #[test]
10015    fn nonid_does_not_expand_on_nonkw_trigger() {
10016        // non-id abbreviations must NOT expand on regular typed chars like space.
10017        let abbrevs = [make_abbrev(";;", "std::endl;")];
10018        let r = expand(&abbrevs, ";;", 0, AbbrevTrigger::NonKeyword(' '));
10019        assert_eq!(r, None);
10020    }
10021
10022    #[test]
10023    fn nonid_expands_on_ctrl_bracket() {
10024        let abbrevs = [make_abbrev(";;", "std::endl;")];
10025        let r = expand(&abbrevs, ";;", 0, AbbrevTrigger::CtrlBracket);
10026        assert_eq!(r, Some((2, "std::endl;".to_string())));
10027    }
10028
10029    // ── multiword rhs ─────────────────────────────────────────────────────────
10030
10031    #[test]
10032    fn multiword_rhs_expansion() {
10033        let abbrevs = [make_abbrev("hw", "hello world")];
10034        let r = expand(&abbrevs, "hw", 0, AbbrevTrigger::NonKeyword(' '));
10035        assert_eq!(r, Some((2, "hello world".to_string())));
10036    }
10037
10038    // ── empty / no match ─────────────────────────────────────────────────────
10039
10040    #[test]
10041    fn no_match_returns_none() {
10042        let abbrevs = [make_abbrev("teh", "the")];
10043        let r = expand(&abbrevs, "xyz", 0, AbbrevTrigger::NonKeyword(' '));
10044        assert_eq!(r, None);
10045    }
10046
10047    #[test]
10048    fn empty_abbrevs_returns_none() {
10049        let r = expand(&[], "teh", 0, AbbrevTrigger::NonKeyword(' '));
10050        assert_eq!(r, None);
10051    }
10052
10053    #[test]
10054    fn empty_before_text_returns_none() {
10055        let abbrevs = [make_abbrev("teh", "the")];
10056        let r = expand(&abbrevs, "", 0, AbbrevTrigger::NonKeyword(' '));
10057        assert_eq!(r, None);
10058    }
10059}
10060
10061// ─── scan_tag_opener / autoclose multibyte tests ──────────────────────────────
10062
10063#[cfg(test)]
10064mod scan_tag_opener_multibyte_tests {
10065    use crate::types::Options;
10066    use crate::{DefaultHost, Editor};
10067    use hjkl_buffer::Buffer;
10068
10069    fn html_editor(content: &str) -> Editor<Buffer, DefaultHost> {
10070        let buf = Buffer::from_str(content);
10071        let host = DefaultHost::new();
10072        let mut ed = Editor::new(buf, host, Options::default());
10073        ed.settings.filetype = "html".to_string();
10074        ed.settings.autoclose_tag = true;
10075        ed.settings.autopair = true;
10076        ed
10077    }
10078
10079    /// Typing `>` after a multibyte char must not panic.
10080    ///
10081    /// With "ñ" in the buffer (ñ = 2 UTF-8 bytes), the cursor is at char
10082    /// col 1 (one past ñ).  `insert_char('>')` calls `scan_tag_opener` with
10083    /// `col = cursor.col = 1`.  Before the fix, `&line[..1]` landed inside
10084    /// the 2-byte ñ sequence → panic "byte index 1 is not a char boundary".
10085    #[test]
10086    fn autoclose_gt_after_multibyte_no_panic() {
10087        let mut ed = html_editor("ñ");
10088        // Cursor starts at col 0 (on ñ). Enter insert at end-of-line.
10089        ed.enter_insert_i(1);
10090        // Move to end (col 1, after ñ).
10091        ed.jump_cursor(0, 1);
10092        // Insert '>' — must not panic.
10093        ed.insert_char('>');
10094        // The `>` should be in the buffer (no autoclose tag fires for bare ">").
10095        let rope = ed.buffer().rope();
10096        let line = hjkl_buffer::rope_line_str(&rope, 0);
10097        assert!(line.contains('>'), "inserted > must appear in buffer");
10098    }
10099
10100    /// Same repro via the direct tag-autoclose path.
10101    ///
10102    /// "ä<div" has a multibyte char at the start.  Positioning the cursor
10103    /// at char col 5 (after 'v') and inserting '>' exercises the
10104    /// scan_tag_opener branch.  Before the fix, `col = cursor.col = 5` and
10105    /// `&line[..5]` is byte index 5, which falls inside 'ä' (2 bytes at
10106    /// positions 0-1) — wait, 'ä'=2 bytes then '<','d','i','v' are 1 byte
10107    /// each, so byte 5 = 'v' (valid boundary).  Use a CJK char (3 bytes)
10108    /// to force a panic at a narrower position:
10109    ///
10110    /// "中<div>": 中 = 3 bytes; after '>', char col 5 → byte index 5.
10111    /// Bytes: 中=0,1,2  <=3  d=4  i=5  v=6  >=7.  Char index 4 = 'i', byte 4
10112    /// is safe. Need char 2 to map to byte 5 → that's inside '<'.
10113    ///
10114    /// Simplest panic case: "ñ>" (ñ=2 bytes, >=1 byte):
10115    /// char 0=ñ, char 1=>; cursor.col=1, &line[..1] = byte 1 = 0xb1 inside ñ → PANIC.
10116    #[test]
10117    fn autoclose_gt_direct_after_multibyte_no_panic() {
10118        // "ñ>" already in buffer — cursor at char col 1 (the '>').
10119        // We'll test by inserting '>' after 'ñ' from scratch.
10120        let mut ed = html_editor("ñ");
10121        ed.enter_insert_i(1);
10122        ed.jump_cursor(0, 1); // char col 1 = one past ñ
10123        // Insert '>' — before fix: scan_tag_opener("ñ>", 1) → &"ñ>"[..1] panics.
10124        ed.insert_char('>');
10125        let rope = ed.buffer().rope();
10126        let line = hjkl_buffer::rope_line_str(&rope, 0);
10127        assert!(
10128            line.contains('>'),
10129            "inserted > must appear in buffer, got: {line:?}"
10130        );
10131    }
10132}