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}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub enum Motion {
243    Left,
244    Right,
245    Up,
246    Down,
247    WordFwd,
248    BigWordFwd,
249    WordBack,
250    BigWordBack,
251    WordEnd,
252    BigWordEnd,
253    /// `ge` — backward word end.
254    WordEndBack,
255    /// `gE` — backward WORD end.
256    BigWordEndBack,
257    LineStart,
258    FirstNonBlank,
259    LineEnd,
260    FileTop,
261    FileBottom,
262    Find {
263        ch: char,
264        forward: bool,
265        till: bool,
266    },
267    FindRepeat {
268        reverse: bool,
269    },
270    MatchBracket,
271    WordAtCursor {
272        forward: bool,
273        /// `*` / `#` use `\bword\b` boundaries; `g*` / `g#` drop them so
274        /// the search hits substrings (e.g. `foo` matches inside `foobar`).
275        whole_word: bool,
276    },
277    /// `n` / `N` — repeat the last `/` or `?` search.
278    SearchNext {
279        reverse: bool,
280    },
281    /// `H` — cursor to viewport top (plus `count - 1` rows down).
282    ViewportTop,
283    /// `M` — cursor to viewport middle.
284    ViewportMiddle,
285    /// `L` — cursor to viewport bottom (minus `count - 1` rows up).
286    ViewportBottom,
287    /// `g_` — last non-blank char on the line.
288    LastNonBlank,
289    /// `gM` — cursor to the middle char column of the current line
290    /// (`floor(chars / 2)`). Vim's variant ignoring screen wrap.
291    LineMiddle,
292    /// `{` — previous paragraph (preceding blank line, or top).
293    ParagraphPrev,
294    /// `}` — next paragraph (following blank line, or bottom).
295    ParagraphNext,
296    /// `(` — previous sentence boundary.
297    SentencePrev,
298    /// `)` — next sentence boundary.
299    SentenceNext,
300    /// `gj` — `count` visual rows down (one screen segment per step
301    /// under `:set wrap`; falls back to `Down` otherwise).
302    ScreenDown,
303    /// `gk` — `count` visual rows up; mirror of [`Motion::ScreenDown`].
304    ScreenUp,
305    /// `[[` — backward to the previous `{` at column 0 (C section header).
306    /// Charwise exclusive; count-aware.
307    SectionBackward,
308    /// `]]` — forward to the next `{` at column 0. Charwise exclusive.
309    SectionForward,
310    /// `[]` — backward to the previous `}` at column 0 (C section end).
311    /// Charwise exclusive; count-aware.
312    SectionEndBackward,
313    /// `][` — forward to the next `}` at column 0. Charwise exclusive.
314    SectionEndForward,
315    /// `+` / `<CR>` — first non-blank of the next line. Linewise.
316    FirstNonBlankNextLine,
317    /// `-` — first non-blank of the previous line. Linewise.
318    FirstNonBlankPrevLine,
319    /// `_` — first non-blank of `count-1` lines down (count=1 = current line). Linewise.
320    FirstNonBlankLine,
321    /// `{count}|` — jump to column `count` on the current line (1-based;
322    /// no count or count=0 → column 1 → index 0). Clamped to line length.
323    GotoColumn,
324}
325
326#[derive(Debug, Clone, Copy, PartialEq, Eq)]
327pub enum TextObject {
328    Word {
329        big: bool,
330    },
331    Quote(char),
332    Bracket(char),
333    Paragraph,
334    /// `it` / `at` — XML/HTML-style tag pair. `inner = true` covers
335    /// content between `>` and `</`; `inner = false` covers the open
336    /// tag through the close tag inclusive.
337    XmlTag,
338    /// `is` / `as` — sentence: a run ending at `.`, `?`, or `!`
339    /// followed by whitespace or end-of-line. `inner = true` covers
340    /// the sentence text only; `inner = false` includes trailing
341    /// whitespace.
342    Sentence,
343}
344
345/// Classification determines how operators treat the range end.
346#[derive(Debug, Clone, Copy, PartialEq, Eq)]
347pub enum RangeKind {
348    /// Range end is exclusive (end column not included). Typical: h, l, w, 0, $.
349    Exclusive,
350    /// Range end is inclusive. Typical: e, f, t, %.
351    Inclusive,
352    /// Whole lines from top row to bottom row. Typical: j, k, gg, G.
353    Linewise,
354}
355
356// ─── Dot-repeat storage ────────────────────────────────────────────────────
357
358/// Information needed to replay a mutating change via `.`.
359#[derive(Debug, Clone)]
360pub enum LastChange {
361    /// Operator over a motion.
362    OpMotion {
363        op: Operator,
364        motion: Motion,
365        count: usize,
366        inserted: Option<String>,
367    },
368    /// Operator over a text-object.
369    OpTextObj {
370        op: Operator,
371        obj: TextObject,
372        inner: bool,
373        inserted: Option<String>,
374    },
375    /// `dd`, `cc`, `yy` with a count.
376    LineOp {
377        op: Operator,
378        count: usize,
379        inserted: Option<String>,
380    },
381    /// `x`, `X` with a count.
382    CharDel { forward: bool, count: usize },
383    /// `r<ch>` with a count.
384    ReplaceChar { ch: char, count: usize },
385    /// `~` with a count.
386    ToggleCase { count: usize },
387    /// `J` with a count.
388    JoinLine { count: usize },
389    /// `p` / `P` with a count.
390    Paste { before: bool, count: usize },
391    /// `D` (delete to EOL).
392    DeleteToEol { inserted: Option<String> },
393    /// `o` / `O` + the inserted text.
394    OpenLine { above: bool, inserted: String },
395    /// `i`/`I`/`a`/`A` + inserted text.
396    InsertAt {
397        entry: InsertEntry,
398        inserted: String,
399        count: usize,
400    },
401}
402
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404pub enum InsertEntry {
405    I,
406    A,
407    ShiftI,
408    ShiftA,
409}
410
411// ─── VimState ──────────────────────────────────────────────────────────────
412
413/// Tracks which kind of horizontal jump was last performed so `;` / `,`
414/// can dispatch to the correct repeat handler.
415///
416/// - `FindChar` — last horizontal motion was `f`/`F`/`t`/`T`; `;`/`,`
417///   repeats via `Motion::FindRepeat`.
418/// - `Sneak` — last horizontal motion was `s`/`S` sneak; `;`/`,` repeats
419///   via `apply_sneak` with the stored digraph.
420/// - `None` — no horizontal motion yet; `;`/`,` are no-ops for both.
421#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
422pub enum LastHorizontalMotion {
423    #[default]
424    None,
425    FindChar,
426    Sneak,
427}
428
429#[derive(Default)]
430pub struct VimState {
431    /// Internal FSM mode. Kept in sync with `current_mode` after every
432    /// `step`. Phase 6.6b: promoted from private to `pub` so the FSM
433    /// body (moving to hjkl-vim in 6.6c–6.6g) can read/write it directly
434    /// until the migration is complete.
435    pub mode: Mode,
436    /// Two-key chord in progress. `Pending::None` when idle.
437    pub pending: Pending,
438    /// Digit prefix accumulated before an operator or motion. `0` means
439    /// no prefix was typed (treated as 1 by most commands).
440    pub count: usize,
441    /// Last `f`/`F`/`t`/`T` target, for `;` / `,` repeat.
442    pub last_find: Option<(char, bool, bool)>,
443    /// Most-recent mutating command for `.` dot-repeat.
444    pub last_change: Option<LastChange>,
445    /// Captured on insert-mode entry: count, buffer snapshot, entry kind.
446    pub insert_session: Option<InsertSession>,
447    /// (row, col) anchor for char-wise Visual mode. Set on entry, used
448    /// to compute the highlight range and the operator range without
449    /// relying on tui-textarea's live selection.
450    pub visual_anchor: (usize, usize),
451    /// Row anchor for VisualLine mode.
452    pub visual_line_anchor: usize,
453    /// (row, col) anchor for VisualBlock mode. The live cursor is the
454    /// opposite corner.
455    pub block_anchor: (usize, usize),
456    /// Intended "virtual" column for the block's active corner. j/k
457    /// clamp cursor.col to shorter rows, which would collapse the
458    /// block across ragged content — so we remember the desired column
459    /// separately and use it for block bounds / insert-column
460    /// computations. Updated by h/l only.
461    pub block_vcol: usize,
462    /// Track whether the last yank/cut was linewise (drives `p`/`P` layout).
463    pub yank_linewise: bool,
464    /// Active register selector — set by `"reg` prefix, consumed by
465    /// the next y / d / c / p. `None` falls back to the unnamed `"`.
466    pub pending_register: Option<char>,
467    /// Recording target — set by `q{reg}`, cleared by a bare `q`.
468    /// While `Some`, every consumed `Input` is appended to
469    /// `recording_keys`.
470    pub recording_macro: Option<char>,
471    /// Keys recorded into the in-progress macro. On `q` finish, these
472    /// are encoded via [`crate::input::encode_macro`] and written to
473    /// the matching named register slot, so macros and yanks share a
474    /// single store.
475    pub recording_keys: Vec<crate::input::Input>,
476    /// Set during `@reg` replay so the recorder doesn't capture the
477    /// replayed keystrokes a second time.
478    pub replaying_macro: bool,
479    /// Last register played via `@reg`. `@@` re-plays this one.
480    pub last_macro: Option<char>,
481    /// Position of the most recent buffer mutation. Surfaced via
482    /// the `'.` / `` `. `` marks for quick "back to last edit".
483    pub last_edit_pos: Option<(usize, usize)>,
484    /// Position where the cursor was when insert mode last exited (Esc).
485    /// Used by `gi` to return to the exact (row, col) where the user
486    /// last typed, matching vim's `:h gi`.
487    pub last_insert_pos: Option<(usize, usize)>,
488    /// Bounded ring of recent edit positions (newest at the back).
489    /// `g;` walks toward older entries, `g,` toward newer ones. Capped
490    /// at [`CHANGE_LIST_MAX`].
491    pub change_list: Vec<(usize, usize)>,
492    /// Index into `change_list` while walking. `None` outside a walk —
493    /// any new edit clears it (and trims forward entries past it).
494    pub change_list_cursor: Option<usize>,
495    /// Snapshot of the last visual selection for `gv` re-entry.
496    /// Stored on every Visual / VisualLine / VisualBlock exit.
497    pub last_visual: Option<LastVisual>,
498    /// `zz` / `zt` / `zb` set this so the end-of-step scrolloff
499    /// pass doesn't override the user's explicit viewport pinning.
500    /// Cleared every step.
501    pub viewport_pinned: bool,
502    /// Set while replaying `.` / last-change so we don't re-record it.
503    pub replaying: bool,
504    /// Entered Normal from Insert via `Ctrl-o`; after the next complete
505    /// normal-mode command we return to Insert.
506    pub one_shot_normal: bool,
507    /// Live `/` or `?` prompt. `None` outside search-prompt mode.
508    pub search_prompt: Option<SearchPrompt>,
509    /// Most recent committed search pattern. Surfaced to host apps via
510    /// [`Editor::last_search`] so their status line can render a hint
511    /// and so `n` / `N` have something to repeat.
512    pub last_search: Option<String>,
513    /// Direction of the last committed search. `n` repeats this; `N`
514    /// inverts it. Defaults to forward so a never-searched buffer's
515    /// `n` still walks downward.
516    pub last_search_forward: bool,
517    /// Back half of the jumplist — `Ctrl-o` pops from here. Populated
518    /// with the pre-motion cursor when a "big jump" motion fires
519    /// (`gg`/`G`, `%`, `*`/`#`, `n`/`N`, `H`/`M`/`L`, committed `/` or
520    /// `?`). Capped at 100 entries.
521    pub jump_back: Vec<(usize, usize)>,
522    /// Forward half — `Ctrl-i` pops from here. Cleared by any new big
523    /// jump, matching vim's "branch off trims forward history" rule.
524    pub jump_fwd: Vec<(usize, usize)>,
525    /// Set by `Ctrl-R` in insert mode while waiting for the register
526    /// selector. The next typed char names the register; its contents
527    /// are inserted inline at the cursor and the flag clears.
528    pub insert_pending_register: bool,
529    /// Stashed start position for the `[` mark on a Change operation.
530    /// Set to `top` before the cut in `run_operator_over_range` (Change
531    /// arm); consumed by `finish_insert_session` on Esc-from-insert
532    /// when the reason is `AfterChange`. Mirrors vim's `:h '[` / `:h ']`
533    /// rule that `[` = start of change, `]` = last typed char on exit.
534    pub change_mark_start: Option<(usize, usize)>,
535    /// Bounded history of committed `/` / `?` search patterns. Newest
536    /// entries are at the back; capped at [`SEARCH_HISTORY_MAX`] to
537    /// avoid unbounded growth on long sessions.
538    pub search_history: Vec<String>,
539    /// Index into `search_history` while the user walks past patterns
540    /// in the prompt via `Ctrl-P` / `Ctrl-N`. `None` outside that walk
541    /// — typing or backspacing in the prompt resets it so the next
542    /// `Ctrl-P` starts from the most recent entry again.
543    pub search_history_cursor: Option<usize>,
544    /// Wall-clock instant of the last keystroke. Drives the
545    /// `:set timeoutlen` multi-key timeout — if `now() - last_input_at`
546    /// exceeds the configured budget, any pending prefix is cleared
547    /// before the new key dispatches. `None` before the first key.
548    /// 0.0.29 (Patch B): `:set timeoutlen` math now reads
549    /// [`crate::types::Host::now`] via `last_input_host_at`. This
550    /// `Instant`-flavoured field stays for snapshot tests that still
551    /// observe it directly.
552    pub last_input_at: Option<std::time::Instant>,
553    /// `Host::now()` reading at the last keystroke. Drives
554    /// `:set timeoutlen` so macro replay / headless drivers stay
555    /// deterministic regardless of wall-clock skew.
556    pub last_input_host_at: Option<core::time::Duration>,
557    /// Canonical current mode. Mirrors `mode` (the FSM-internal field)
558    /// AND is written by every Phase 6.3 primitive (`set_mode`,
559    /// `enter_visual_char_bridge`, …). Once the FSM is gone this is the
560    /// sole source of truth; until then both fields are kept in sync.
561    /// Initialized to `Normal` via `#[derive(Default)]`.
562    pub(crate) current_mode: crate::VimMode,
563    /// Most recent successful :s invocation. Stored so :& / :&& can repeat it.
564    pub last_substitute: Option<crate::substitute::SubstituteCmd>,
565    /// Stack of auto-inserted closing characters awaiting skip-over.
566    ///
567    /// Each entry `(row, col, ch)` records where autopair placed a close
568    /// character. When the next typed char matches `ch` AND the cursor is
569    /// immediately before that position, the engine advances past it
570    /// ("skip-over") instead of inserting. The stack is cleared on any
571    /// cursor motion, mode change, or out-of-pair edit.
572    pub pending_closes: Vec<(usize, usize, char)>,
573    /// Last sneak digraph and direction: `Some(((c1, c2), forward))`.
574    /// Used by `;` / `,` sneak-repeat when `last_horizontal_motion == Sneak`.
575    pub last_sneak: Option<((char, char), bool)>,
576    /// Tracks which kind of horizontal motion was last performed, so `;` / `,`
577    /// can dispatch to sneak-repeat vs. find-char-repeat as appropriate.
578    pub last_horizontal_motion: LastHorizontalMotion,
579}
580
581pub(crate) const SEARCH_HISTORY_MAX: usize = 100;
582pub(crate) const CHANGE_LIST_MAX: usize = 100;
583
584/// Active `/` or `?` search prompt. Text mutations drive the textarea's
585/// live search pattern so matches highlight as the user types.
586#[derive(Debug, Clone)]
587pub struct SearchPrompt {
588    pub text: String,
589    pub cursor: usize,
590    pub forward: bool,
591}
592
593#[derive(Debug, Clone)]
594pub struct InsertSession {
595    pub count: usize,
596    /// Min/max row visited during this session. Widens on every key.
597    pub row_min: usize,
598    pub row_max: usize,
599    /// O(1) rope snapshot of the full buffer at session entry. Used to
600    /// diff the affected row window at finish without being fooled by
601    /// cursor navigation through rows the user never edited.
602    /// `ropey::Rope::clone` is Arc-clone — no byte copying.
603    pub before_rope: ropey::Rope,
604    pub reason: InsertReason,
605}
606
607#[derive(Debug, Clone)]
608pub enum InsertReason {
609    /// Plain entry via i/I/a/A — recorded as `InsertAt`.
610    Enter(InsertEntry),
611    /// Entry via `o`/`O` — records OpenLine on Esc.
612    Open { above: bool },
613    /// Entry via an operator's change side-effect. Retro-fills the
614    /// stored last-change's `inserted` field on Esc.
615    AfterChange,
616    /// Entry via `C` (delete to EOL + insert).
617    DeleteToEol,
618    /// Entry via an insert triggered during dot-replay — don't touch
619    /// last_change because the outer replay will restore it.
620    ReplayOnly,
621    /// `I` or `A` from VisualBlock: insert the typed text at `col` on
622    /// every row in `top..=bot`. `col` is the start column for `I`, the
623    /// one-past-block-end column for `A`.
624    BlockEdge { top: usize, bot: usize, col: usize },
625    /// `c` from VisualBlock: block content deleted, then user types
626    /// replacement text replicated across all block rows on Esc. Cursor
627    /// advances to the last typed char after replication (unlike BlockEdge
628    /// which leaves cursor at the insertion column).
629    BlockChange { top: usize, bot: usize, col: usize },
630    /// `R` — Replace mode. Each typed char overwrites the cell under
631    /// the cursor instead of inserting; at end-of-line the session
632    /// falls through to insert (same as vim).
633    Replace,
634}
635
636/// Saved visual-mode anchor + cursor for `gv` (re-enters the last
637/// visual selection). `mode` carries which visual flavour to
638/// restore; `anchor` / `cursor` mean different things per flavour:
639///
640/// - `Visual`     — `anchor` is the char-wise visual anchor.
641/// - `VisualLine` — `anchor.0` is the `visual_line_anchor` row;
642///   `anchor.1` is unused.
643/// - `VisualBlock`— `anchor` is `block_anchor`, `block_vcol` is the
644///   sticky vcol that survives j/k clamping.
645#[derive(Debug, Clone, Copy)]
646pub struct LastVisual {
647    pub mode: Mode,
648    pub anchor: (usize, usize),
649    pub cursor: (usize, usize),
650    pub block_vcol: usize,
651}
652
653impl VimState {
654    pub fn public_mode(&self) -> VimMode {
655        match self.mode {
656            Mode::Normal => VimMode::Normal,
657            Mode::Insert => VimMode::Insert,
658            Mode::Visual => VimMode::Visual,
659            Mode::VisualLine => VimMode::VisualLine,
660            Mode::VisualBlock => VimMode::VisualBlock,
661        }
662    }
663
664    pub fn force_normal(&mut self) {
665        self.mode = Mode::Normal;
666        self.pending = Pending::None;
667        self.count = 0;
668        self.insert_session = None;
669        // Phase 6.3: keep current_mode in sync for callers that bypass step().
670        self.current_mode = crate::VimMode::Normal;
671    }
672
673    /// Reset every prefix-tracking field so the next keystroke starts
674    /// a fresh sequence. Drives `:set timeoutlen` — when the user
675    /// pauses past the configured budget, `hjkl_vim::dispatch_input` calls
676    /// this before dispatching the new key.
677    ///
678    /// Resets: `pending`, `count`, `pending_register`,
679    /// `insert_pending_register`. Does NOT touch `mode`,
680    /// `insert_session`, marks, jump list, or visual anchors —
681    /// those aren't part of the in-flight chord.
682    pub(crate) fn clear_pending_prefix(&mut self) {
683        self.pending = Pending::None;
684        self.count = 0;
685        self.pending_register = None;
686        self.insert_pending_register = false;
687    }
688
689    /// Widen the active insert session's row window to include `row`. Called
690    /// by the Phase 6.1 public `Editor::insert_*` methods after each
691    /// mutation so `finish_insert_session` diffs the right range on Esc.
692    /// No-op when no insert session is active (e.g. calling from Normal mode).
693    pub(crate) fn widen_insert_row(&mut self, row: usize) {
694        if let Some(ref mut session) = self.insert_session {
695            session.row_min = session.row_min.min(row);
696            session.row_max = session.row_max.max(row);
697        }
698    }
699
700    pub fn is_visual(&self) -> bool {
701        matches!(
702            self.mode,
703            Mode::Visual | Mode::VisualLine | Mode::VisualBlock
704        )
705    }
706
707    pub fn is_visual_char(&self) -> bool {
708        self.mode == Mode::Visual
709    }
710
711    /// The pending repeat count (typed digits before a motion/operator),
712    /// or `None` when no digits are pending. Zero is treated as absent.
713    pub(crate) fn pending_count_val(&self) -> Option<u32> {
714        if self.count == 0 {
715            None
716        } else {
717            Some(self.count as u32)
718        }
719    }
720
721    /// `true` when an in-flight chord is awaiting more keys. Inverse of
722    /// `matches!(self.pending, Pending::None)`.
723    pub(crate) fn is_chord_pending(&self) -> bool {
724        !matches!(self.pending, Pending::None)
725    }
726
727    /// Return a single char representing the pending operator, if any.
728    /// Used by host apps (status line "showcmd" area) to display e.g.
729    /// `d`, `y`, `c` while waiting for a motion.
730    pub(crate) fn pending_op_char(&self) -> Option<char> {
731        let op = match &self.pending {
732            Pending::Op { op, .. }
733            | Pending::OpTextObj { op, .. }
734            | Pending::OpG { op, .. }
735            | Pending::OpFind { op, .. }
736            | Pending::OpSquareBracketOpen { op, .. }
737            | Pending::OpSquareBracketClose { op, .. } => Some(*op),
738            _ => None,
739        };
740        op.map(|o| match o {
741            Operator::Delete => 'd',
742            Operator::Change => 'c',
743            Operator::Yank => 'y',
744            Operator::Uppercase => 'U',
745            Operator::Lowercase => 'u',
746            Operator::ToggleCase => '~',
747            Operator::Indent => '>',
748            Operator::Outdent => '<',
749            Operator::Fold => 'z',
750            Operator::Reflow => 'q',
751            Operator::ReflowKeepCursor => 'w',
752            Operator::AutoIndent => '=',
753            Operator::Filter => '!',
754            // `gc` prefix — doubled as `gcc`.
755            Operator::Comment => 'c',
756        })
757    }
758}
759
760// ─── Entry point ───────────────────────────────────────────────────────────
761
762/// Open the `/` (forward) or `?` (backward) search prompt. Clears any
763/// live search highlight until the user commits a query. `last_search`
764/// is preserved so an empty `<CR>` can re-run the previous pattern.
765pub(crate) fn enter_search<H: crate::types::Host>(
766    ed: &mut Editor<hjkl_buffer::Buffer, H>,
767    forward: bool,
768) {
769    ed.vim.search_prompt = Some(SearchPrompt {
770        text: String::new(),
771        cursor: 0,
772        forward,
773    });
774    ed.vim.search_history_cursor = None;
775    // 0.0.37: clear via the engine search state (the buffer-side
776    // bridge from 0.0.35 was removed in this patch — the `BufferView`
777    // renderer reads the pattern from `Editor::search_state()`).
778    ed.set_search_pattern(None);
779}
780
781/// `g;` / `g,` body. `dir = -1` walks toward older entries (g;),
782/// `dir = 1` toward newer (g,). `count` repeats the step. Stops at
783/// the ends of the ring; off-ring positions are silently ignored.
784fn walk_change_list<H: crate::types::Host>(
785    ed: &mut Editor<hjkl_buffer::Buffer, H>,
786    dir: isize,
787    count: usize,
788) {
789    if ed.vim.change_list.is_empty() {
790        return;
791    }
792    let len = ed.vim.change_list.len();
793    let mut idx: isize = match (ed.vim.change_list_cursor, dir) {
794        (None, -1) => len as isize - 1,
795        (None, 1) => return, // already past the newest entry
796        (Some(i), -1) => i as isize - 1,
797        (Some(i), 1) => i as isize + 1,
798        _ => return,
799    };
800    for _ in 1..count {
801        let next = idx + dir;
802        if next < 0 || next >= len as isize {
803            break;
804        }
805        idx = next;
806    }
807    if idx < 0 || idx >= len as isize {
808        return;
809    }
810    let idx = idx as usize;
811    ed.vim.change_list_cursor = Some(idx);
812    let (row, col) = ed.vim.change_list[idx];
813    ed.jump_cursor(row, col);
814}
815
816/// `Ctrl-R {reg}` body — insert the named register's contents at the
817/// cursor as charwise text. Embedded newlines split lines naturally via
818/// `Edit::InsertStr`. Unknown selectors and empty slots are no-ops so
819/// stray keystrokes don't mutate the buffer.
820fn insert_register_text<H: crate::types::Host>(
821    ed: &mut Editor<hjkl_buffer::Buffer, H>,
822    selector: char,
823) {
824    use hjkl_buffer::Edit;
825    let text = match ed.registers().read(selector) {
826        Some(slot) if !slot.text.is_empty() => slot.text.clone(),
827        _ => return,
828    };
829    ed.sync_buffer_content_from_textarea();
830    let cursor = buf_cursor_pos(&ed.buffer);
831    ed.mutate_edit(Edit::InsertStr {
832        at: cursor,
833        text: text.clone(),
834    });
835    // Advance cursor to the end of the inserted payload — multi-line
836    // pastes land on the last inserted row at the post-text column.
837    let mut row = cursor.row;
838    let mut col = cursor.col;
839    for ch in text.chars() {
840        if ch == '\n' {
841            row += 1;
842            col = 0;
843        } else {
844            col += 1;
845        }
846    }
847    buf_set_cursor_rc(&mut ed.buffer, row, col);
848    ed.push_buffer_cursor_to_textarea();
849    ed.mark_content_dirty();
850    if let Some(ref mut session) = ed.vim.insert_session {
851        session.row_min = session.row_min.min(row);
852        session.row_max = session.row_max.max(row);
853    }
854}
855
856/// Compute the indent string to insert at the start of a new line
857/// after Enter is pressed at `cursor`. Walks the smartindent rules:
858///
859/// - autoindent off → empty string
860/// - autoindent on  → copy prev line's leading whitespace
861/// - smartindent on → bump one `shiftwidth` if prev line's last
862///   non-whitespace char is `{` / `(` / `[`
863///
864/// Indent unit (used for the smartindent bump):
865///
866/// - `expandtab && softtabstop > 0` → `softtabstop` spaces
867/// - `expandtab` → `shiftwidth` spaces
868/// - `!expandtab` → one literal `\t`
869///
870/// This is the placeholder for a future tree-sitter indent provider:
871/// when a language has an `indents.scm` query, the engine will route
872/// the same call through that provider and only fall back to this
873/// heuristic when no query matches.
874pub(super) fn compute_enter_indent(settings: &crate::editor::Settings, prev_line: &str) -> String {
875    if !settings.autoindent {
876        return String::new();
877    }
878    // Copy the prev line's leading whitespace (autoindent base).
879    let base: String = prev_line
880        .chars()
881        .take_while(|c| *c == ' ' || *c == '\t')
882        .collect();
883
884    if settings.smartindent {
885        let unit = if settings.expandtab {
886            if settings.softtabstop > 0 {
887                " ".repeat(settings.softtabstop)
888            } else {
889                " ".repeat(settings.shiftwidth)
890            }
891        } else {
892            "\t".to_string()
893        };
894
895        // Open-bracket bump — language-agnostic.
896        let last_non_ws = prev_line.chars().rev().find(|c| !c.is_whitespace());
897        if matches!(last_non_ws, Some('{' | '(' | '[')) {
898            return format!("{base}{unit}");
899        }
900
901        // HTML-family opening-tag bump: `<head>` / `<div class="...">`.
902        // Gated on filetype so Rust generics like `Vec<T>` don't trigger.
903        // Reuses scan_tag_opener which already filters self-closing and
904        // void elements.
905        if is_html_filetype(&settings.filetype) {
906            let trimmed_end_len = prev_line
907                .trim_end_matches(|c: char| c.is_whitespace())
908                .len();
909            let trimmed = &prev_line[..trimmed_end_len];
910            if let Some(stripped) = trimmed.strip_suffix('>')
911                && scan_tag_opener(trimmed, stripped.len()).is_some()
912            {
913                return format!("{base}{unit}");
914            }
915        }
916    }
917
918    base
919}
920
921// ── Comment-continuation helpers ──────────────────────────────────────────
922
923/// Return the ordered (longest-first) list of line-comment prefixes for
924/// `lang`. Each prefix includes one trailing space (e.g. `"// "`).
925/// The same table lives in `hjkl-lang::comment` for the `gc` toggle (#187).
926fn comment_prefixes_for_lang(lang: &str) -> &'static [&'static str] {
927    match lang {
928        "rust" => &["/// ", "//! ", "// "],
929        "c" | "cpp" => &["// "],
930        "python" | "sh" | "bash" | "zsh" | "fish" | "toml" | "yaml" => &["# "],
931        "lua" => &["-- "],
932        "sql" => &["-- "],
933        "vim" | "viml" => &["\" "],
934        _ => &[],
935    }
936}
937
938/// Detect whether `line` starts with a known comment prefix for `lang`.
939///
940/// Returns `Some((indent, prefix))` where `indent` is the leading whitespace
941/// of the line and `prefix` is the canonical (with trailing space) comment
942/// marker. Returns `None` when the line is not a recognised comment.
943pub(crate) fn detect_comment_on_line(lang: &str, line: &str) -> Option<(String, &'static str)> {
944    let indent_end = line
945        .char_indices()
946        .find(|(_, c)| *c != ' ' && *c != '\t')
947        .map(|(i, _)| i)
948        .unwrap_or(line.len());
949    let indent = line[..indent_end].to_string();
950    let rest = &line[indent_end..];
951    for &prefix in comment_prefixes_for_lang(lang) {
952        if rest.starts_with(prefix) {
953            return Some((indent, prefix));
954        }
955        // Also match the bare prefix (line that is exactly `//` with no
956        // trailing content).
957        let bare = prefix.trim_end_matches(' ');
958        if rest == bare || rest.starts_with(&format!("{bare} ")) {
959            return Some((indent, prefix));
960        }
961    }
962    None
963}
964
965/// Given the current `row` in `buffer` and the active `settings`, return the
966/// string to prepend on the new line when comment-continuation fires.
967///
968/// Returns `Some("<indent><prefix>")` when the row is a comment line and
969/// continuation is appropriate, `None` otherwise. The caller appends the
970/// string after the `\n` they are about to insert.
971pub(crate) fn continue_comment(
972    buffer: &hjkl_buffer::Buffer,
973    settings: &crate::editor::Settings,
974    row: usize,
975) -> Option<String> {
976    if settings.filetype.is_empty() {
977        return None;
978    }
979    let line = crate::buf_helpers::buf_line(buffer, row)?;
980    let (indent, prefix) = detect_comment_on_line(&settings.filetype, &line)?;
981    Some(format!("{indent}{prefix}"))
982}
983
984/// Strip one indent unit from the beginning of `line` and insert `ch`
985/// instead. Returns `true` when it consumed the keystroke (dedent +
986/// insert), `false` when the caller should insert normally.
987///
988/// Dedent fires when:
989///   - `smartindent` is on
990///   - `ch` is `}` / `)` / `]`
991///   - all bytes BEFORE the cursor on the current line are whitespace
992///   - there is at least one full indent unit of leading whitespace
993fn try_dedent_close_bracket<H: crate::types::Host>(
994    ed: &mut Editor<hjkl_buffer::Buffer, H>,
995    cursor: hjkl_buffer::Position,
996    ch: char,
997) -> bool {
998    use hjkl_buffer::{Edit, MotionKind, Position};
999
1000    if !ed.settings.smartindent {
1001        return false;
1002    }
1003    if !matches!(ch, '}' | ')' | ']') {
1004        return false;
1005    }
1006
1007    let line = match buf_line(&ed.buffer, cursor.row) {
1008        Some(l) => l.to_string(),
1009        None => return false,
1010    };
1011
1012    // All chars before cursor must be whitespace.
1013    let before: String = line.chars().take(cursor.col).collect();
1014    if !before.chars().all(|c| c == ' ' || c == '\t') {
1015        return false;
1016    }
1017    if before.is_empty() {
1018        // Nothing to strip — just insert normally (cursor at col 0).
1019        return false;
1020    }
1021
1022    // Compute indent unit.
1023    let unit_len: usize = if ed.settings.expandtab {
1024        if ed.settings.softtabstop > 0 {
1025            ed.settings.softtabstop
1026        } else {
1027            ed.settings.shiftwidth
1028        }
1029    } else {
1030        // Tab: one literal tab character.
1031        1
1032    };
1033
1034    // Check there's at least one full unit to strip.
1035    let strip_len = if ed.settings.expandtab {
1036        // Count leading spaces; need at least `unit_len`.
1037        let spaces = before.chars().filter(|c| *c == ' ').count();
1038        if spaces < unit_len {
1039            return false;
1040        }
1041        unit_len
1042    } else {
1043        // noexpandtab: strip one leading tab.
1044        if !before.starts_with('\t') {
1045            return false;
1046        }
1047        1
1048    };
1049
1050    // Delete the leading `strip_len` chars of the current line.
1051    ed.mutate_edit(Edit::DeleteRange {
1052        start: Position::new(cursor.row, 0),
1053        end: Position::new(cursor.row, strip_len),
1054        kind: MotionKind::Char,
1055    });
1056    // Insert the close bracket at column 0 (after the delete the cursor
1057    // is still positioned at the end of the remaining whitespace; the
1058    // delete moved the text so the cursor is now at col = before.len() -
1059    // strip_len).
1060    let new_col = cursor.col.saturating_sub(strip_len);
1061    ed.mutate_edit(Edit::InsertChar {
1062        at: Position::new(cursor.row, new_col),
1063        ch,
1064    });
1065    true
1066}
1067
1068fn finish_insert_session<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
1069    let Some(session) = ed.vim.insert_session.take() else {
1070        return;
1071    };
1072    let after_rope = crate::types::Query::rope(&ed.buffer);
1073    // Clamp both slices to their respective bounds — the buffer may have
1074    // grown (Enter splits rows) or shrunk (Backspace joins rows) during
1075    // the session, so row_max can overshoot either side.
1076    let before_n = session.before_rope.len_lines();
1077    let after_n = after_rope.len_lines();
1078    let after_end = session.row_max.min(after_n.saturating_sub(1));
1079    let before_end = session.row_max.min(before_n.saturating_sub(1));
1080    let before = if before_end >= session.row_min && session.row_min < before_n {
1081        rope_row_range_str(&session.before_rope, session.row_min, before_end)
1082    } else {
1083        String::new()
1084    };
1085    let after = if after_end >= session.row_min && session.row_min < after_n {
1086        rope_row_range_str(&after_rope, session.row_min, after_end)
1087    } else {
1088        String::new()
1089    };
1090    let inserted = extract_inserted(&before, &after);
1091    if !inserted.is_empty() && session.count > 1 && !ed.vim.replaying {
1092        use hjkl_buffer::{Edit, Position};
1093        for _ in 0..session.count - 1 {
1094            let (row, col) = ed.cursor();
1095            ed.mutate_edit(Edit::InsertStr {
1096                at: Position::new(row, col),
1097                text: inserted.clone(),
1098            });
1099        }
1100    }
1101    // Helper: replicate `inserted` text across block rows top+1..=bot at `col`,
1102    // padding short rows to reach `col` first. Returns without touching the
1103    // cursor — callers position the cursor afterward according to their needs.
1104    fn replicate_block_text<H: crate::types::Host>(
1105        ed: &mut Editor<hjkl_buffer::Buffer, H>,
1106        inserted: &str,
1107        top: usize,
1108        bot: usize,
1109        col: usize,
1110    ) {
1111        use hjkl_buffer::{Edit, Position};
1112        for r in (top + 1)..=bot {
1113            let line_len = buf_line_chars(&ed.buffer, r);
1114            if col > line_len {
1115                let pad: String = std::iter::repeat_n(' ', col - line_len).collect();
1116                ed.mutate_edit(Edit::InsertStr {
1117                    at: Position::new(r, line_len),
1118                    text: pad,
1119                });
1120            }
1121            ed.mutate_edit(Edit::InsertStr {
1122                at: Position::new(r, col),
1123                text: inserted.to_string(),
1124            });
1125        }
1126    }
1127
1128    if let InsertReason::BlockEdge { top, bot, col } = session.reason {
1129        // `I` / `A` from VisualBlock: replicate text across rows; cursor
1130        // stays at the block-start column (vim leaves cursor there).
1131        if !inserted.is_empty() && top < bot && !ed.vim.replaying {
1132            replicate_block_text(ed, &inserted, top, bot, col);
1133            buf_set_cursor_rc(&mut ed.buffer, top, col);
1134            ed.push_buffer_cursor_to_textarea();
1135        }
1136        return;
1137    }
1138    if let InsertReason::BlockChange { top, bot, col } = session.reason {
1139        // `c` from VisualBlock: replicate text across rows; cursor advances
1140        // to `col + ins_chars` (pre-step-back) so the Esc step-back lands
1141        // on the last typed char (col + ins_chars - 1), matching nvim.
1142        if !inserted.is_empty() && top < bot && !ed.vim.replaying {
1143            replicate_block_text(ed, &inserted, top, bot, col);
1144            let ins_chars = inserted.chars().count();
1145            let line_len = buf_line_chars(&ed.buffer, top);
1146            let target_col = (col + ins_chars).min(line_len);
1147            buf_set_cursor_rc(&mut ed.buffer, top, target_col);
1148            ed.push_buffer_cursor_to_textarea();
1149        }
1150        return;
1151    }
1152    if ed.vim.replaying {
1153        return;
1154    }
1155    match session.reason {
1156        InsertReason::Enter(entry) => {
1157            ed.vim.last_change = Some(LastChange::InsertAt {
1158                entry,
1159                inserted,
1160                count: session.count,
1161            });
1162        }
1163        InsertReason::Open { above } => {
1164            ed.vim.last_change = Some(LastChange::OpenLine { above, inserted });
1165        }
1166        InsertReason::AfterChange => {
1167            if let Some(
1168                LastChange::OpMotion { inserted: ins, .. }
1169                | LastChange::OpTextObj { inserted: ins, .. }
1170                | LastChange::LineOp { inserted: ins, .. },
1171            ) = ed.vim.last_change.as_mut()
1172            {
1173                *ins = Some(inserted);
1174            }
1175            // Vim `:h '[` / `:h ']`: on change, `[` = start of the
1176            // changed range (stashed before the cut), `]` = the cursor
1177            // at Esc time (last inserted char, before the step-back).
1178            // When nothing was typed cursor still sits at the change
1179            // start, satisfying vim's "both at start" parity for `c<m><Esc>`.
1180            if let Some(start) = ed.vim.change_mark_start.take() {
1181                let end = ed.cursor();
1182                ed.set_mark('[', start);
1183                ed.set_mark(']', end);
1184            }
1185        }
1186        InsertReason::DeleteToEol => {
1187            ed.vim.last_change = Some(LastChange::DeleteToEol {
1188                inserted: Some(inserted),
1189            });
1190        }
1191        InsertReason::ReplayOnly => {}
1192        InsertReason::BlockEdge { .. } => unreachable!("handled above"),
1193        InsertReason::BlockChange { .. } => unreachable!("handled above"),
1194        InsertReason::Replace => {
1195            // Record overstrike sessions as DeleteToEol-style — replay
1196            // re-types each character but doesn't try to restore prior
1197            // content (vim's R has its own replay path; this is the
1198            // pragmatic approximation).
1199            ed.vim.last_change = Some(LastChange::DeleteToEol {
1200                inserted: Some(inserted),
1201            });
1202        }
1203    }
1204}
1205
1206pub(crate) fn begin_insert<H: crate::types::Host>(
1207    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1208    count: usize,
1209    reason: InsertReason,
1210) {
1211    let record = !matches!(reason, InsertReason::ReplayOnly);
1212    if record {
1213        ed.push_undo();
1214    }
1215    let reason = if ed.vim.replaying {
1216        InsertReason::ReplayOnly
1217    } else {
1218        reason
1219    };
1220    let (row, _) = ed.cursor();
1221    ed.vim.insert_session = Some(InsertSession {
1222        count,
1223        row_min: row,
1224        row_max: row,
1225        before_rope: crate::types::Query::rope(&ed.buffer),
1226        reason,
1227    });
1228    ed.vim.mode = Mode::Insert;
1229    // Phase 6.3: keep current_mode in sync for callers that bypass step().
1230    ed.vim.current_mode = crate::VimMode::Insert;
1231}
1232
1233/// `:set undobreak` semantics for insert-mode motions. When the
1234/// toggle is on, a non-character keystroke that moves the cursor
1235/// (arrow keys, Home/End, mouse click) ends the current undo group
1236/// and starts a new one mid-session. After this, a subsequent `u`
1237/// in normal mode reverts only the post-break run, leaving the
1238/// pre-break edits in place — matching vim's behaviour.
1239///
1240/// Implementation: snapshot the current buffer onto the undo stack
1241/// (the new break point) and reset the active `InsertSession`'s
1242/// `before_lines` so `finish_insert_session`'s diff window only
1243/// captures the post-break run for `last_change` / dot-repeat.
1244///
1245/// During replay we skip the break — replay shouldn't pollute the
1246/// undo stack with intra-replay snapshots.
1247pub(crate) fn break_undo_group_in_insert<H: crate::types::Host>(
1248    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1249) {
1250    if !ed.settings.undo_break_on_motion {
1251        return;
1252    }
1253    if ed.vim.replaying {
1254        return;
1255    }
1256    if ed.vim.insert_session.is_none() {
1257        return;
1258    }
1259    ed.push_undo();
1260    let before_rope = crate::types::Query::rope(&ed.buffer);
1261    let row = crate::types::Cursor::cursor(&ed.buffer).line as usize;
1262    if let Some(ref mut session) = ed.vim.insert_session {
1263        session.before_rope = before_rope;
1264        session.row_min = row;
1265        session.row_max = row;
1266    }
1267}
1268
1269// ─── Phase 6.1: public insert-mode primitives ──────────────────────────────
1270//
1271// Each `pub(crate)` free function below implements one insert-mode action.
1272// hjkl-vim's insert dispatcher calls them through `Editor::insert_*` methods.
1273// External callers can also invoke the public Editor methods directly.
1274//
1275// Invariants every function upholds:
1276//   - Opens with `ed.sync_buffer_content_from_textarea()` (no-op, kept for
1277//     forward compatibility once textarea is gone).
1278//   - All buffer mutations go through `ed.mutate_edit(...)` so dirty flag,
1279//     undo, change-list, content-edit fan-out all fire uniformly.
1280//   - Navigation-only functions call `break_undo_group_in_insert` when the
1281//     FSM did so, then return `false` (no mutation).
1282//   - After mutations, `ed.push_buffer_cursor_to_textarea()` is called
1283//     (currently a no-op but kept for migration hygiene).
1284//   - Returns `true` when the buffer was mutated, `false` otherwise.
1285
1286/// Return the filetype-gated autopair close character for `open`, or `None`
1287/// when no pairing applies.
1288///
1289/// Rules:
1290/// - `(` → `)`, `[` → `]`, `{` → `}` always.
1291/// - `"` → `"` and `` ` `` → `` ` `` always, EXCEPT when the previous two
1292///   characters are the same quote — typing the third `` ` `` of a markdown
1293///   code-fence or the third `"` of a Python triple-quoted string must
1294///   emit a bare quote (no close) so the result is `` ``` `` / `"""` and
1295///   not `` ```` `` / `""""`.
1296/// - `<` → `>` only for HTML/XML family filetypes.
1297/// - `'` → `'` unless the character immediately before the cursor is
1298///   `[A-Za-z]` (prose apostrophe guard — "don't" stays "don't"), AND the
1299///   same triple-quote guard as `"` / `` ` ``.
1300fn autopair_close_for(
1301    ch: char,
1302    filetype: &str,
1303    prev_char: Option<char>,
1304    prev2_char: Option<char>,
1305) -> Option<char> {
1306    // Triple-quote guard — applies to ", `, and ' (the three quote chars
1307    // that get same-char pairing). When the previous two characters are
1308    // both this same quote, treat the third keystroke as a bare insert so
1309    // the user lands on `` ``` `` / `"""` / `'''` without a stray fourth
1310    // quote dangling after the cursor.
1311    let is_triple_quote_third =
1312        matches!(ch, '"' | '`' | '\'') && prev_char == Some(ch) && prev2_char == Some(ch);
1313
1314    match ch {
1315        '(' => Some(')'),
1316        '[' => Some(']'),
1317        '{' => Some('}'),
1318        '"' => {
1319            if is_triple_quote_third {
1320                None
1321            } else {
1322                Some('"')
1323            }
1324        }
1325        '`' => {
1326            if is_triple_quote_third {
1327                None
1328            } else {
1329                Some('`')
1330            }
1331        }
1332        '<' => {
1333            if is_html_filetype(filetype) {
1334                Some('>')
1335            } else {
1336                None
1337            }
1338        }
1339        '\'' => {
1340            if is_triple_quote_third {
1341                return None;
1342            }
1343            // Prose guard: skip pairing when the previous char is a letter
1344            // (covers "don't", "it's", etc.).
1345            if prev_char.map(|c| c.is_ascii_alphabetic()).unwrap_or(false) {
1346                None
1347            } else {
1348                Some('\'')
1349            }
1350        }
1351        _ => None,
1352    }
1353}
1354
1355/// Detect a markdown / doc-comment code-fence opener on the current line.
1356///
1357/// Returns `Some(fence)` (the backtick run that should be used as the
1358/// closing fence) when:
1359/// - The cursor is at the end of the visible line (`cursor_col` equals the
1360///   line's char count).
1361/// - The line, after leading whitespace, begins with 3+ backticks followed
1362///   by a non-empty language tag matching `[A-Za-z0-9_+-]+` and nothing
1363///   else (no trailing space, no extra text).
1364///
1365/// The language tag requirement is deliberate: a bare ` ``` ` could be
1366/// either an opener OR a closer, and we don't track fence parity here.
1367/// Requiring a tag means we only fire when the user is clearly opening a
1368/// fence (` ```rust `, ` ```ts `, etc.).
1369fn detect_code_fence_opener(line: &str, cursor_col: usize) -> Option<String> {
1370    if cursor_col != line.chars().count() {
1371        return None;
1372    }
1373    let trimmed = line.trim_start();
1374    let backtick_run = trimmed.chars().take_while(|c| *c == '`').count();
1375    if backtick_run < 3 {
1376        return None;
1377    }
1378    let rest = &trimmed[backtick_run..];
1379    if rest.is_empty() {
1380        return None;
1381    }
1382    let all_lang_chars = rest
1383        .chars()
1384        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '+' || c == '-');
1385    if !all_lang_chars {
1386        return None;
1387    }
1388    Some("`".repeat(backtick_run))
1389}
1390
1391/// Filetypes that get HTML/XML-family treatment (`<` pairing + tag autoclose).
1392fn is_html_filetype(ft: &str) -> bool {
1393    matches!(
1394        ft,
1395        "html" | "xml" | "svg" | "jsx" | "tsx" | "vue" | "svelte"
1396    )
1397}
1398
1399// ── Paired-tag auto-rename (issue #182) ────────────────────────────────────
1400//
1401// When the user edits the name of an HTML/XML opening tag (e.g. `ci<` to
1402// change-inner the tag name, type a new name, then `<Esc>`), the matching
1403// closing tag should rename automatically so the pair stays in sync.
1404// Same on the close side: edit `</X>` → its opener gets renamed.
1405//
1406// Trigger: leave_insert_to_normal_bridge calls sync_paired_tag_on_exit, which
1407// inspects the cursor's current position. If the cursor sits inside a tag
1408// name and the paired tag has a different name, rewrite the paired tag.
1409//
1410// Pairing uses a stack-based scan so nested same-name tags
1411// (`<div><div></div></div>`) pair correctly.
1412
1413/// Tag kind detected at a cursor position.
1414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1415enum TagKind {
1416    Open,
1417    Close,
1418}
1419
1420/// A single tag instance located in the buffer.
1421#[derive(Debug, Clone, PartialEq, Eq)]
1422struct TagSpan {
1423    kind: TagKind,
1424    name: String,
1425    /// Row index in the buffer.
1426    row: usize,
1427    /// Char-column range of the tag NAME (excluding `<`, `</`, attributes, `>`).
1428    name_start_col: usize,
1429    name_end_col: usize,
1430}
1431
1432/// Detect the tag containing `(row, col)` in `line`. Returns the tag kind
1433/// (Open / Close), its name, and the char-column range of that name.
1434/// Returns `None` when the cursor is not inside a tag-name region.
1435fn detect_tag_at_cursor(line: &str, row: usize, col: usize) -> Option<TagSpan> {
1436    let chars: Vec<char> = line.chars().collect();
1437    // Find the nearest `<` at or before the cursor column.
1438    let mut lt = None;
1439    let mut i = col.min(chars.len());
1440    while i > 0 {
1441        i -= 1;
1442        let c = chars[i];
1443        if c == '<' {
1444            lt = Some(i);
1445            break;
1446        }
1447        // Bail if we cross a `>` (we're outside any open tag).
1448        if c == '>' {
1449            return None;
1450        }
1451    }
1452    let lt = lt?;
1453    // Detect close tag (`</`) vs open (`<`).
1454    let (kind, name_start) = if chars.get(lt + 1) == Some(&'/') {
1455        (TagKind::Close, lt + 2)
1456    } else {
1457        (TagKind::Open, lt + 1)
1458    };
1459    // First char of the name must be a letter.
1460    let first = chars.get(name_start)?;
1461    if !first.is_ascii_alphabetic() {
1462        return None;
1463    }
1464    // Tag name = [A-Za-z][A-Za-z0-9-]*
1465    let mut name_end = name_start;
1466    while name_end < chars.len()
1467        && (chars[name_end].is_ascii_alphanumeric() || chars[name_end] == '-')
1468    {
1469        name_end += 1;
1470    }
1471    // Cursor must be inside the name range (inclusive of both ends so that
1472    // landing right after the name still resolves — vim Insert leaves the
1473    // cursor one past the last typed char).
1474    if col < name_start || col > name_end {
1475        return None;
1476    }
1477    let name: String = chars[name_start..name_end].iter().collect();
1478    Some(TagSpan {
1479        kind,
1480        name,
1481        row,
1482        name_start_col: name_start,
1483        name_end_col: name_end,
1484    })
1485}
1486
1487/// Scan the buffer to find the structural partner of `anchor` using a
1488/// depth counter. Names are intentionally NOT compared during the scan —
1489/// the anchor is the source of truth and the partner inherits its name.
1490/// Otherwise an in-flight rename (the whole point of this feature) would
1491/// look like a malformed pair and bail.
1492///
1493/// Forward scan from an opener: opens increment depth, closes decrement
1494/// depth. The close that brings depth back to zero is the partner.
1495/// Backward scan from a closer is symmetric (closes increment, opens
1496/// decrement).
1497///
1498/// Returns `None` when the buffer end is reached before depth hits zero
1499/// (orphan tag or malformed input).
1500fn find_matching_tag(buffer: &hjkl_buffer::Buffer, anchor: &TagSpan) -> Option<TagSpan> {
1501    let row_count = buffer.row_count();
1502    let scan_forward = anchor.kind == TagKind::Open;
1503    let row_iter: Box<dyn Iterator<Item = usize>> = if scan_forward {
1504        Box::new(anchor.row..row_count)
1505    } else {
1506        Box::new((0..=anchor.row).rev())
1507    };
1508    let push_kind = if scan_forward {
1509        TagKind::Open
1510    } else {
1511        TagKind::Close
1512    };
1513    let mut depth: usize = 1;
1514
1515    for r in row_iter {
1516        let line = buf_line(buffer, r)?;
1517        let chars: Vec<char> = line.chars().collect();
1518        let tags = scan_line_tags(&chars, r);
1519        let tags_iter: Box<dyn Iterator<Item = TagSpan>> = if scan_forward {
1520            Box::new(tags.into_iter())
1521        } else {
1522            Box::new(tags.into_iter().rev())
1523        };
1524        for tag in tags_iter {
1525            // Skip the anchor itself when we walk over its line.
1526            if r == anchor.row
1527                && tag.name_start_col == anchor.name_start_col
1528                && tag.kind == anchor.kind
1529            {
1530                continue;
1531            }
1532            // On the anchor's own row, gate by direction relative to anchor
1533            // so the scan only inspects tags AFTER the anchor (forward) or
1534            // BEFORE the anchor (backward).
1535            if r == anchor.row {
1536                if scan_forward && tag.name_start_col < anchor.name_start_col {
1537                    continue;
1538                }
1539                if !scan_forward && tag.name_start_col > anchor.name_start_col {
1540                    continue;
1541                }
1542            }
1543            if tag.kind == push_kind {
1544                depth += 1;
1545            } else {
1546                depth -= 1;
1547                if depth == 0 {
1548                    return Some(tag);
1549                }
1550            }
1551        }
1552    }
1553    None
1554}
1555
1556/// Collect all tag opens / closes on a single line in left-to-right order.
1557/// Skips comments (`<!-- ... -->`) and self-closing tags (`<br />`), and
1558/// excludes void HTML elements that don't form a pair.
1559fn scan_line_tags(chars: &[char], row: usize) -> Vec<TagSpan> {
1560    let mut out = Vec::new();
1561    let n = chars.len();
1562    let mut i = 0;
1563    while i < n {
1564        if chars[i] != '<' {
1565            i += 1;
1566            continue;
1567        }
1568        // `<!--` comment — skip to `-->`.
1569        if chars[i..].starts_with(&['<', '!', '-', '-']) {
1570            let mut j = i + 4;
1571            while j + 2 < n && !(chars[j] == '-' && chars[j + 1] == '-' && chars[j + 2] == '>') {
1572                j += 1;
1573            }
1574            i = (j + 3).min(n);
1575            continue;
1576        }
1577        let (kind, name_start) = if chars.get(i + 1) == Some(&'/') {
1578            (TagKind::Close, i + 2)
1579        } else {
1580            (TagKind::Open, i + 1)
1581        };
1582        // Validate name start.
1583        if chars
1584            .get(name_start)
1585            .is_none_or(|c| !c.is_ascii_alphabetic())
1586        {
1587            i += 1;
1588            continue;
1589        }
1590        let mut name_end = name_start;
1591        while name_end < n && (chars[name_end].is_ascii_alphanumeric() || chars[name_end] == '-') {
1592            name_end += 1;
1593        }
1594        // Find the closing `>` to know whether this tag is self-closing.
1595        let mut k = name_end;
1596        let mut self_closing = false;
1597        while k < n {
1598            if chars[k] == '>' {
1599                if k > name_end && chars[k - 1] == '/' {
1600                    self_closing = true;
1601                }
1602                break;
1603            }
1604            k += 1;
1605        }
1606        if k >= n {
1607            // Unterminated tag on this line — bail.
1608            break;
1609        }
1610        let name: String = chars[name_start..name_end].iter().collect();
1611        // Skip self-closing and void elements (no pair).
1612        if !(self_closing || kind == TagKind::Open && is_void_element(&name)) {
1613            out.push(TagSpan {
1614                kind,
1615                name,
1616                row,
1617                name_start_col: name_start,
1618                name_end_col: name_end,
1619            });
1620        }
1621        i = k + 1;
1622    }
1623    out
1624}
1625
1626/// If the cursor sits inside an HTML/XML tag name AND the paired tag's name
1627/// differs, rewrite the paired tag's name to match. Called from
1628/// `leave_insert_to_normal_bridge` so the magical sync fires exactly when
1629/// the user finishes editing.
1630pub(crate) fn sync_paired_tag_on_exit<H: crate::types::Host>(
1631    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1632) {
1633    if !is_html_filetype(&ed.settings.filetype) {
1634        return;
1635    }
1636    let (row, col) = ed.cursor();
1637    let line = match buf_line(&ed.buffer, row) {
1638        Some(l) => l,
1639        None => return,
1640    };
1641    let anchor = match detect_tag_at_cursor(&line, row, col) {
1642        Some(t) => t,
1643        None => return,
1644    };
1645    let partner = match find_matching_tag(&ed.buffer, &anchor) {
1646        Some(t) => t,
1647        None => return,
1648    };
1649    if partner.name == anchor.name {
1650        return;
1651    }
1652    // Rewrite the partner's name range with the anchor's name.
1653    use hjkl_buffer::{Edit, MotionKind, Position};
1654    let start = Position::new(partner.row, partner.name_start_col);
1655    let end = Position::new(partner.row, partner.name_end_col);
1656    ed.mutate_edit(Edit::DeleteRange {
1657        start,
1658        end,
1659        kind: MotionKind::Char,
1660    });
1661    ed.mutate_edit(Edit::InsertStr {
1662        at: start,
1663        text: anchor.name.clone(),
1664    });
1665    // Restore the user's cursor — mutate_edit may have moved it during the
1666    // partner-side rewrite when the partner is on a row before the cursor.
1667    buf_set_cursor_rc(&mut ed.buffer, row, col);
1668    ed.push_buffer_cursor_to_textarea();
1669}
1670
1671/// Void HTML elements that must never get an auto-close tag.
1672fn is_void_element(tag: &str) -> bool {
1673    matches!(
1674        tag.to_ascii_lowercase().as_str(),
1675        "area"
1676            | "base"
1677            | "br"
1678            | "col"
1679            | "embed"
1680            | "hr"
1681            | "img"
1682            | "input"
1683            | "link"
1684            | "meta"
1685            | "param"
1686            | "source"
1687            | "track"
1688            | "wbr"
1689    )
1690}
1691
1692/// Scan backward from `col` (exclusive) in `line` for a `<tagname…` opener.
1693///
1694/// Returns `Some(tag_name)` when:
1695/// - An opening `<` is found
1696/// - The tag name matches `[A-Za-z][A-Za-z0-9-]*`
1697/// - The tag is not self-closing (does not end with `/` before `>`)
1698/// - The tag is not a void element
1699///
1700/// Returns `None` otherwise (no opener, self-closing, void, or malformed).
1701fn scan_tag_opener(line: &str, col: usize) -> Option<String> {
1702    // col is where `>` was just inserted (the char is already in the line).
1703    // We look at the slice BEFORE the `>`.
1704    let before = if col > 0 { &line[..col] } else { return None };
1705
1706    // Walk backward to find the matching `<`.
1707    let lt_pos = before.rfind('<')?;
1708    let inner = &before[lt_pos + 1..]; // e.g. "div class=\"foo\""
1709
1710    // A `!` opener is a comment/doctype — skip.
1711    if inner.starts_with('!') {
1712        return None;
1713    }
1714    // Self-closing if the last non-space char before `>` was `/`.
1715    if inner.trim_end().ends_with('/') {
1716        return None;
1717    }
1718
1719    // Extract tag name: first token of `inner`.
1720    let tag: String = inner
1721        .chars()
1722        .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
1723        .collect();
1724    if tag.is_empty() {
1725        return None;
1726    }
1727    // First char must be a letter.
1728    if !tag
1729        .chars()
1730        .next()
1731        .map(|c| c.is_ascii_alphabetic())
1732        .unwrap_or(false)
1733    {
1734        return None;
1735    }
1736    if is_void_element(&tag) {
1737        return None;
1738    }
1739    Some(tag)
1740}
1741
1742/// Insert a single character at the cursor. Handles replace-mode overstrike
1743/// (when `InsertSession::reason` is `Replace`) and smart-indent dedent of
1744/// closing brackets (}/)]/). Also handles autopair insertion and skip-over.
1745/// Returns `true`.
1746pub(crate) fn insert_char_bridge<H: crate::types::Host>(
1747    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1748    ch: char,
1749) -> bool {
1750    use hjkl_buffer::{Edit, MotionKind, Position};
1751    ed.sync_buffer_content_from_textarea();
1752    let cursor = buf_cursor_pos(&ed.buffer);
1753    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
1754    let in_replace = matches!(
1755        ed.vim.insert_session.as_ref().map(|s| &s.reason),
1756        Some(InsertReason::Replace)
1757    );
1758
1759    // ── Skip-over: if the typed char matches the top of the pending-closes
1760    // stack AND the char currently under the cursor IS that close char,
1761    // pop the stack and advance the cursor instead of inserting.
1762    //
1763    // We check the actual char in the buffer (not a stored col) so that
1764    // characters typed between the pair don't invalidate the skip — the
1765    // close char shifts right as the user types inside, but the buffer
1766    // char check always finds it correctly.
1767    if !in_replace
1768        && !ed.vim.pending_closes.is_empty()
1769        && let Some(&(pr, _pc, pch)) = ed.vim.pending_closes.last()
1770        && ch == pch
1771        && cursor.row == pr
1772    {
1773        let char_at_cursor =
1774            buf_line(&ed.buffer, cursor.row).and_then(|l| l.chars().nth(cursor.col));
1775        if char_at_cursor == Some(ch) {
1776            ed.vim.pending_closes.pop();
1777            // For `>` skip-over in HTML/XML: also run tag autoclose.
1778            let filetype = ed.settings.filetype.clone();
1779            let autoclose_tag = ed.settings.autoclose_tag;
1780            if ch == '>' && autoclose_tag && is_html_filetype(&filetype) {
1781                // Skip past the `>` that was auto-inserted.
1782                let new_col = cursor.col + 1;
1783                buf_set_cursor_rc(&mut ed.buffer, cursor.row, new_col);
1784                // Now check for tag autoclose on the line up to new_col.
1785                if let Some(line) = buf_line(&ed.buffer, cursor.row)
1786                    && let Some(tag) = scan_tag_opener(&line, new_col.saturating_sub(1))
1787                {
1788                    let close_tag = format!("</{tag}>");
1789                    let insert_pos = Position::new(cursor.row, new_col);
1790                    ed.mutate_edit(Edit::InsertStr {
1791                        at: insert_pos,
1792                        text: close_tag,
1793                    });
1794                    // Cursor stays at new_col (between > and </tag>).
1795                    buf_set_cursor_rc(&mut ed.buffer, cursor.row, new_col);
1796                }
1797            } else {
1798                buf_set_cursor_rc(&mut ed.buffer, cursor.row, cursor.col + 1);
1799            }
1800            ed.push_buffer_cursor_to_textarea();
1801            return true;
1802        }
1803    }
1804
1805    if in_replace && cursor.col < line_chars {
1806        // Replace mode: clear pending closes (edit outside the pair).
1807        ed.vim.pending_closes.clear();
1808        ed.mutate_edit(Edit::DeleteRange {
1809            start: cursor,
1810            end: Position::new(cursor.row, cursor.col + 1),
1811            kind: MotionKind::Char,
1812        });
1813        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
1814    } else if !try_dedent_close_bracket(ed, cursor, ch) {
1815        // Normal insert. Check autopair first.
1816        let autopair = ed.settings.autopair;
1817        let filetype = ed.settings.filetype.clone();
1818        let autoclose_tag = ed.settings.autoclose_tag;
1819
1820        let (prev_char, prev2_char) = {
1821            let line = buf_line(&ed.buffer, cursor.row).unwrap_or_default();
1822            let chars: Vec<char> = line.chars().collect();
1823            let p1 = if cursor.col > 0 {
1824                chars.get(cursor.col - 1).copied()
1825            } else {
1826                None
1827            };
1828            let p2 = if cursor.col > 1 {
1829                chars.get(cursor.col - 2).copied()
1830            } else {
1831                None
1832            };
1833            (p1, p2)
1834        };
1835
1836        if autopair {
1837            if let Some(close) = autopair_close_for(ch, &filetype, prev_char, prev2_char) {
1838                // Insert open char.
1839                ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
1840                // Insert close char immediately after the open char.
1841                // After inserting open at cursor, buffer cursor is at cursor.col+1.
1842                let after = Position::new(cursor.row, cursor.col + 1);
1843                ed.mutate_edit(Edit::InsertChar {
1844                    at: after,
1845                    ch: close,
1846                });
1847                // After inserting close, buffer cursor is at cursor.col+2.
1848                // We want cursor between open and close: cursor.col+1.
1849                let between_col = cursor.col + 1;
1850                buf_set_cursor_rc(&mut ed.buffer, cursor.row, between_col);
1851                // Record the close char for skip-over. We store the row and
1852                // the close char; col is not tracked precisely because chars
1853                // typed inside the pair shift the close right. The skip-over
1854                // logic checks the actual buffer char at cursor instead.
1855                ed.vim.pending_closes.push((cursor.row, between_col, close));
1856                ed.push_buffer_cursor_to_textarea();
1857                return true;
1858            }
1859
1860            // Tag autoclose: `>` in HTML/XML family (no prior `<` pair).
1861            // This fires when autopair did NOT match `>` (e.g. `>` was
1862            // typed directly, not via a skip-over of an auto-inserted `>`).
1863            if ch == '>' && autoclose_tag && is_html_filetype(&filetype) {
1864                ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
1865                let new_col = cursor.col + 1;
1866                // scan_tag_opener looks at the line up to (new_col-1), i.e.
1867                // the char just inserted is at index new_col-1.
1868                if let Some(line) = buf_line(&ed.buffer, cursor.row)
1869                    && let Some(tag) = scan_tag_opener(&line, new_col.saturating_sub(1))
1870                {
1871                    let close_tag = format!("</{tag}>");
1872                    let insert_pos = Position::new(cursor.row, new_col);
1873                    ed.mutate_edit(Edit::InsertStr {
1874                        at: insert_pos,
1875                        text: close_tag,
1876                    });
1877                    // Cursor stays at new_col (between `>` and `</tag>`).
1878                    buf_set_cursor_rc(&mut ed.buffer, cursor.row, new_col);
1879                }
1880                ed.push_buffer_cursor_to_textarea();
1881                return true;
1882            }
1883        }
1884
1885        // Plain insert — do not clear the pending-closes stack here.
1886        // The stack is cleared on cursor motion or mode change (Esc).
1887        // Clearing here would prevent skip-over from firing after the
1888        // user types content inside an auto-paired bracket.
1889        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
1890    }
1891    ed.push_buffer_cursor_to_textarea();
1892    true
1893}
1894
1895/// Insert a newline at the cursor, applying autoindent / smartindent and
1896/// optionally continuing a line comment when `formatoptions` has `r`.
1897/// Also handles open-pair-newline: Enter between `{|}` / `(|)` / `[|]`
1898/// produces an indented block with the close on its own line.
1899/// Returns `true`.
1900pub(crate) fn insert_newline_bridge<H: crate::types::Host>(
1901    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1902) -> bool {
1903    use hjkl_buffer::Edit;
1904    ed.sync_buffer_content_from_textarea();
1905    let cursor = buf_cursor_pos(&ed.buffer);
1906    let prev_line = buf_line(&ed.buffer, cursor.row)
1907        .unwrap_or_default()
1908        .to_string();
1909
1910    // Open-pair-newline: if autopair is on and the cursor is between a
1911    // matching open/close bracket pair, split into two newlines so the
1912    // close ends up on its own dedented line.
1913    if ed.settings.autopair && !ed.vim.pending_closes.is_empty() {
1914        // Check: char before cursor is an open bracket AND char at cursor
1915        // is the matching close bracket (from our pending-closes stack).
1916        let prev_char = if cursor.col > 0 {
1917            prev_line.chars().nth(cursor.col - 1)
1918        } else {
1919            None
1920        };
1921        let next_char = prev_line.chars().nth(cursor.col);
1922        let is_open_pair = matches!(
1923            (prev_char, next_char),
1924            (Some('{'), Some('}')) | (Some('('), Some(')')) | (Some('['), Some(']'))
1925        );
1926        if is_open_pair {
1927            // The pending-closes stack refers to the close char at cursor.col.
1928            // We clear it because the newline expansion moves the close.
1929            ed.vim.pending_closes.clear();
1930            // Compute indents: inner gets one extra unit, close gets base.
1931            let base_indent: String = prev_line
1932                .chars()
1933                .take_while(|c| *c == ' ' || *c == '\t')
1934                .collect();
1935            let inner_indent = if ed.settings.expandtab {
1936                let unit = if ed.settings.softtabstop > 0 {
1937                    ed.settings.softtabstop
1938                } else {
1939                    ed.settings.shiftwidth
1940                };
1941                format!("{base_indent}{}", " ".repeat(unit))
1942            } else {
1943                format!("{base_indent}\t")
1944            };
1945            // Insert: \n<inner_indent>\n<base_indent>
1946            // Then cursor lands after the first \n (inside the block).
1947            let text = format!("\n{inner_indent}\n{base_indent}");
1948            ed.mutate_edit(Edit::InsertStr { at: cursor, text });
1949            // Move cursor to end of first new line (inner_indent line).
1950            let new_row = cursor.row + 1;
1951            let new_col = inner_indent.len();
1952            buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
1953            ed.push_buffer_cursor_to_textarea();
1954            return true;
1955        }
1956    }
1957
1958    // Code-fence expansion: line content is ` ``` ` (3+ backticks) followed
1959    // by a non-empty language tag, cursor sits at end of line → insert the
1960    // matching closing fence on the line below and park the cursor on a
1961    // blank middle line. Matches the open-pair-newline shape but for
1962    // markdown / doc-comment code blocks. Gated on a language tag because
1963    // a bare ` ``` ` could just as easily be a closing fence — we'd need
1964    // full document parity tracking to handle that safely, which v1
1965    // doesn't have.
1966    if ed.settings.autopair
1967        && let Some(fence) = detect_code_fence_opener(&prev_line, cursor.col)
1968    {
1969        ed.vim.pending_closes.clear();
1970        let base_indent: String = prev_line
1971            .chars()
1972            .take_while(|c| *c == ' ' || *c == '\t')
1973            .collect();
1974        let text = format!("\n{base_indent}\n{base_indent}{fence}");
1975        ed.mutate_edit(Edit::InsertStr { at: cursor, text });
1976        let new_row = cursor.row + 1;
1977        let new_col = base_indent.chars().count();
1978        buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
1979        ed.push_buffer_cursor_to_textarea();
1980        return true;
1981    }
1982
1983    // formatoptions `r`: continue comment on Enter in insert mode.
1984    let comment_cont = if ed.settings.formatoptions.contains('r') {
1985        continue_comment(&ed.buffer, &ed.settings, cursor.row)
1986    } else {
1987        None
1988    };
1989
1990    // Any Enter clears the pending-closes stack (cursor moved off the pair).
1991    ed.vim.pending_closes.clear();
1992
1993    let text = if let Some(cont) = comment_cont {
1994        // Comment continuation overrides autoindent: the indent is already
1995        // baked into the continuation prefix.
1996        format!("\n{cont}")
1997    } else {
1998        let indent = compute_enter_indent(&ed.settings, &prev_line);
1999        format!("\n{indent}")
2000    };
2001    ed.mutate_edit(Edit::InsertStr { at: cursor, text });
2002    ed.push_buffer_cursor_to_textarea();
2003    true
2004}
2005
2006/// Insert a tab character (or spaces up to the next softtabstop boundary when
2007/// `expandtab` is set). Returns `true`.
2008pub(crate) fn insert_tab_bridge<H: crate::types::Host>(
2009    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2010) -> bool {
2011    use hjkl_buffer::Edit;
2012    ed.sync_buffer_content_from_textarea();
2013    let cursor = buf_cursor_pos(&ed.buffer);
2014    if ed.settings.expandtab {
2015        let sts = ed.settings.softtabstop;
2016        let n = if sts > 0 {
2017            sts - (cursor.col % sts)
2018        } else {
2019            ed.settings.tabstop.max(1)
2020        };
2021        ed.mutate_edit(Edit::InsertStr {
2022            at: cursor,
2023            text: " ".repeat(n),
2024        });
2025    } else {
2026        ed.mutate_edit(Edit::InsertChar {
2027            at: cursor,
2028            ch: '\t',
2029        });
2030    }
2031    ed.push_buffer_cursor_to_textarea();
2032    true
2033}
2034
2035/// Delete the character before the cursor (vim Backspace / `^H`). With
2036/// `softtabstop` active, deletes the entire soft-tab run at an aligned
2037/// boundary. Joins with the previous line when at column 0.
2038///
2039/// **Comment-continuation backspace**: when the current line's entire content
2040/// is the auto-inserted comment prefix (e.g. `// ` with nothing after it),
2041/// a single Backspace removes the whole prefix in one stroke — vim parity.
2042///
2043/// Returns `true` when something was deleted, `false` at the very start of the
2044/// buffer.
2045pub(crate) fn insert_backspace_bridge<H: crate::types::Host>(
2046    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2047) -> bool {
2048    use hjkl_buffer::{Edit, MotionKind, Position};
2049    ed.sync_buffer_content_from_textarea();
2050    let cursor = buf_cursor_pos(&ed.buffer);
2051
2052    // Comment-continuation backspace: if the line is just the prefix (with no
2053    // user content after it), delete the whole prefix in one stroke.
2054    if cursor.col > 0 {
2055        let line = buf_line(&ed.buffer, cursor.row).unwrap_or_default();
2056        if let Some((indent, prefix)) = detect_comment_on_line(&ed.settings.filetype, &line) {
2057            let full_prefix = format!("{indent}{prefix}");
2058            // The cursor must be at the end of (or within) the prefix with no
2059            // additional content after — i.e. the line equals the prefix exactly.
2060            let line_trimmed = line.trim_end_matches(' ');
2061            let prefix_trimmed = full_prefix.trim_end_matches(' ');
2062            if line_trimmed == prefix_trimmed && cursor.col == full_prefix.chars().count() {
2063                // Delete everything from col 0 to cursor.
2064                ed.mutate_edit(Edit::DeleteRange {
2065                    start: Position::new(cursor.row, 0),
2066                    end: cursor,
2067                    kind: MotionKind::Char,
2068                });
2069                ed.push_buffer_cursor_to_textarea();
2070                return true;
2071            }
2072        }
2073    }
2074
2075    let sts = ed.settings.softtabstop;
2076    if sts > 0 && cursor.col >= sts && cursor.col.is_multiple_of(sts) {
2077        let line = buf_line(&ed.buffer, cursor.row).unwrap_or_default();
2078        let chars: Vec<char> = line.chars().collect();
2079        let run_start = cursor.col - sts;
2080        if (run_start..cursor.col).all(|i| chars.get(i).copied() == Some(' ')) {
2081            ed.mutate_edit(Edit::DeleteRange {
2082                start: Position::new(cursor.row, run_start),
2083                end: cursor,
2084                kind: MotionKind::Char,
2085            });
2086            ed.push_buffer_cursor_to_textarea();
2087            return true;
2088        }
2089    }
2090    let result = if cursor.col > 0 {
2091        ed.mutate_edit(Edit::DeleteRange {
2092            start: Position::new(cursor.row, cursor.col - 1),
2093            end: cursor,
2094            kind: MotionKind::Char,
2095        });
2096        true
2097    } else if cursor.row > 0 {
2098        let prev_row = cursor.row - 1;
2099        let prev_chars = buf_line_chars(&ed.buffer, prev_row);
2100        ed.mutate_edit(Edit::JoinLines {
2101            row: prev_row,
2102            count: 1,
2103            with_space: false,
2104        });
2105        buf_set_cursor_rc(&mut ed.buffer, prev_row, prev_chars);
2106        true
2107    } else {
2108        false
2109    };
2110    ed.push_buffer_cursor_to_textarea();
2111    result
2112}
2113
2114/// Delete the character under the cursor (vim `Delete`). Joins with the
2115/// next line when at end-of-line. Returns `true` when something was deleted.
2116pub(crate) fn insert_delete_bridge<H: crate::types::Host>(
2117    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2118) -> bool {
2119    use hjkl_buffer::{Edit, MotionKind, Position};
2120    ed.sync_buffer_content_from_textarea();
2121    let cursor = buf_cursor_pos(&ed.buffer);
2122    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
2123    let result = if cursor.col < line_chars {
2124        ed.mutate_edit(Edit::DeleteRange {
2125            start: cursor,
2126            end: Position::new(cursor.row, cursor.col + 1),
2127            kind: MotionKind::Char,
2128        });
2129        buf_set_cursor_pos(&mut ed.buffer, cursor);
2130        true
2131    } else if cursor.row + 1 < buf_row_count(&ed.buffer) {
2132        ed.mutate_edit(Edit::JoinLines {
2133            row: cursor.row,
2134            count: 1,
2135            with_space: false,
2136        });
2137        buf_set_cursor_pos(&mut ed.buffer, cursor);
2138        true
2139    } else {
2140        false
2141    };
2142    ed.push_buffer_cursor_to_textarea();
2143    result
2144}
2145
2146/// Direction for insert-mode arrow movement.
2147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2148pub enum InsertDir {
2149    Left,
2150    Right,
2151    Up,
2152    Down,
2153}
2154
2155/// Move the cursor one step in `dir`, breaking the undo group per
2156/// `undo_break_on_motion`. Clears the autopair pending-closes stack (cursor
2157/// moved off the pair). Returns `false` (no mutation).
2158pub(crate) fn insert_arrow_bridge<H: crate::types::Host>(
2159    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2160    dir: InsertDir,
2161) -> bool {
2162    ed.sync_buffer_content_from_textarea();
2163    ed.vim.pending_closes.clear();
2164    match dir {
2165        InsertDir::Left => {
2166            crate::motions::move_left(&mut ed.buffer, 1);
2167        }
2168        InsertDir::Right => {
2169            crate::motions::move_right_to_end(&mut ed.buffer, 1);
2170        }
2171        InsertDir::Up => {
2172            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2173            crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
2174        }
2175        InsertDir::Down => {
2176            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2177            crate::motions::move_down(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
2178        }
2179    }
2180    break_undo_group_in_insert(ed);
2181    ed.push_buffer_cursor_to_textarea();
2182    false
2183}
2184
2185/// Move the cursor to the start of the current line, breaking the undo group.
2186/// Clears the autopair pending-closes stack. Returns `false` (no mutation).
2187pub(crate) fn insert_home_bridge<H: crate::types::Host>(
2188    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2189) -> bool {
2190    ed.sync_buffer_content_from_textarea();
2191    ed.vim.pending_closes.clear();
2192    crate::motions::move_line_start(&mut ed.buffer);
2193    break_undo_group_in_insert(ed);
2194    ed.push_buffer_cursor_to_textarea();
2195    false
2196}
2197
2198/// Move the cursor to the end of the current line, breaking the undo group.
2199/// Clears the autopair pending-closes stack. Returns `false` (no mutation).
2200pub(crate) fn insert_end_bridge<H: crate::types::Host>(
2201    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2202) -> bool {
2203    ed.sync_buffer_content_from_textarea();
2204    ed.vim.pending_closes.clear();
2205    crate::motions::move_line_end(&mut ed.buffer);
2206    break_undo_group_in_insert(ed);
2207    ed.push_buffer_cursor_to_textarea();
2208    false
2209}
2210
2211/// Scroll up one full viewport height, moving the cursor with it.
2212/// Breaks the undo group. Returns `false` (no mutation).
2213pub(crate) fn insert_pageup_bridge<H: crate::types::Host>(
2214    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2215    viewport_h: u16,
2216) -> bool {
2217    let rows = viewport_h.saturating_sub(2).max(1) as isize;
2218    scroll_cursor_rows(ed, -rows);
2219    false
2220}
2221
2222/// Scroll down one full viewport height, moving the cursor with it.
2223/// Breaks the undo group. Returns `false` (no mutation).
2224pub(crate) fn insert_pagedown_bridge<H: crate::types::Host>(
2225    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2226    viewport_h: u16,
2227) -> bool {
2228    let rows = viewport_h.saturating_sub(2).max(1) as isize;
2229    scroll_cursor_rows(ed, rows);
2230    false
2231}
2232
2233/// Delete from the cursor back to the start of the previous word (`Ctrl-W`).
2234/// At col 0, joins with the previous line (vim semantics). Returns `true`
2235/// when something was deleted.
2236pub(crate) fn insert_ctrl_w_bridge<H: crate::types::Host>(
2237    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2238) -> bool {
2239    use hjkl_buffer::{Edit, MotionKind};
2240    ed.sync_buffer_content_from_textarea();
2241    let cursor = buf_cursor_pos(&ed.buffer);
2242    if cursor.row == 0 && cursor.col == 0 {
2243        return true;
2244    }
2245    crate::motions::move_word_back(&mut ed.buffer, false, 1, &ed.settings.iskeyword);
2246    let word_start = buf_cursor_pos(&ed.buffer);
2247    if word_start == cursor {
2248        return true;
2249    }
2250    buf_set_cursor_pos(&mut ed.buffer, cursor);
2251    ed.mutate_edit(Edit::DeleteRange {
2252        start: word_start,
2253        end: cursor,
2254        kind: MotionKind::Char,
2255    });
2256    ed.push_buffer_cursor_to_textarea();
2257    true
2258}
2259
2260/// Delete from the cursor back to the start of the current line (`Ctrl-U`).
2261/// No-op when already at column 0. Returns `true` when something was deleted.
2262pub(crate) fn insert_ctrl_u_bridge<H: crate::types::Host>(
2263    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2264) -> bool {
2265    use hjkl_buffer::{Edit, MotionKind, Position};
2266    ed.sync_buffer_content_from_textarea();
2267    let cursor = buf_cursor_pos(&ed.buffer);
2268    if cursor.col > 0 {
2269        ed.mutate_edit(Edit::DeleteRange {
2270            start: Position::new(cursor.row, 0),
2271            end: cursor,
2272            kind: MotionKind::Char,
2273        });
2274        ed.push_buffer_cursor_to_textarea();
2275    }
2276    true
2277}
2278
2279/// Delete one character backwards (`Ctrl-H`) — alias for Backspace in insert
2280/// mode. Joins with the previous line when at col 0. Returns `true` when
2281/// something was deleted.
2282pub(crate) fn insert_ctrl_h_bridge<H: crate::types::Host>(
2283    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2284) -> bool {
2285    use hjkl_buffer::{Edit, MotionKind, Position};
2286    ed.sync_buffer_content_from_textarea();
2287    let cursor = buf_cursor_pos(&ed.buffer);
2288    if cursor.col > 0 {
2289        ed.mutate_edit(Edit::DeleteRange {
2290            start: Position::new(cursor.row, cursor.col - 1),
2291            end: cursor,
2292            kind: MotionKind::Char,
2293        });
2294    } else if cursor.row > 0 {
2295        let prev_row = cursor.row - 1;
2296        let prev_chars = buf_line_chars(&ed.buffer, prev_row);
2297        ed.mutate_edit(Edit::JoinLines {
2298            row: prev_row,
2299            count: 1,
2300            with_space: false,
2301        });
2302        buf_set_cursor_rc(&mut ed.buffer, prev_row, prev_chars);
2303    }
2304    ed.push_buffer_cursor_to_textarea();
2305    true
2306}
2307
2308/// Indent the current line by one `shiftwidth` and shift the cursor right by
2309/// the same amount (`Ctrl-T`). Returns `true`.
2310pub(crate) fn insert_ctrl_t_bridge<H: crate::types::Host>(
2311    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2312) -> bool {
2313    let (row, col) = ed.cursor();
2314    let sw = ed.settings().shiftwidth;
2315    indent_rows(ed, row, row, 1);
2316    ed.jump_cursor(row, col + sw);
2317    true
2318}
2319
2320/// Outdent the current line by up to one `shiftwidth` and shift the cursor
2321/// left by the amount stripped (`Ctrl-D`). Returns `true`.
2322pub(crate) fn insert_ctrl_d_bridge<H: crate::types::Host>(
2323    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2324) -> bool {
2325    let (row, col) = ed.cursor();
2326    let before_len = buf_line_bytes(&ed.buffer, row);
2327    outdent_rows(ed, row, row, 1);
2328    let after_len = buf_line_bytes(&ed.buffer, row);
2329    let stripped = before_len.saturating_sub(after_len);
2330    let new_col = col.saturating_sub(stripped);
2331    ed.jump_cursor(row, new_col);
2332    true
2333}
2334
2335/// Enter "one-shot normal" mode (`Ctrl-O`): suspend insert for the next
2336/// complete normal-mode command, then return to insert. Returns `false`
2337/// (no buffer mutation — only mode state changes).
2338pub(crate) fn insert_ctrl_o_bridge<H: crate::types::Host>(
2339    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2340) -> bool {
2341    ed.vim.one_shot_normal = true;
2342    ed.vim.mode = Mode::Normal;
2343    // Phase 6.3: keep current_mode in sync for callers that bypass step().
2344    ed.vim.current_mode = crate::VimMode::Normal;
2345    false
2346}
2347
2348/// Arm the register-paste selector (`Ctrl-R`): the next typed character
2349/// names the register whose text will be inserted inline. Returns `false`
2350/// (no buffer mutation yet — mutation happens when the register char arrives).
2351pub(crate) fn insert_ctrl_r_bridge<H: crate::types::Host>(
2352    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2353) -> bool {
2354    ed.vim.insert_pending_register = true;
2355    false
2356}
2357
2358/// Paste the contents of `reg` at the cursor (the body of `Ctrl-R {reg}`).
2359/// Unknown or empty registers are a no-op. Returns `true` when text was
2360/// inserted.
2361pub(crate) fn insert_paste_register_bridge<H: crate::types::Host>(
2362    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2363    reg: char,
2364) -> bool {
2365    insert_register_text(ed, reg);
2366    // insert_register_text already calls mark_content_dirty internally;
2367    // return true to signal that the session row window should be widened.
2368    true
2369}
2370
2371/// Exit insert mode to Normal: finish the insert session, step the cursor one
2372/// cell left (vim convention), record the `gi` target, and update the sticky
2373/// column. Clears the autopair pending-closes stack. Returns `true` (always
2374/// consumed — even if no buffer mutation, the mode change itself is a
2375/// meaningful step).
2376pub(crate) fn leave_insert_to_normal_bridge<H: crate::types::Host>(
2377    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2378) -> bool {
2379    ed.vim.pending_closes.clear();
2380    finish_insert_session(ed);
2381    // Paired-tag auto-rename (issue #182). Must run BEFORE the cursor moves
2382    // left (the move-left is vim's "leave-insert cursor adjustment"; the
2383    // sync needs the post-insert cursor position to detect the tag name).
2384    sync_paired_tag_on_exit(ed);
2385    ed.vim.mode = Mode::Normal;
2386    // Phase 6.3: keep current_mode in sync for callers that bypass step().
2387    ed.vim.current_mode = crate::VimMode::Normal;
2388    let col = ed.cursor().1;
2389    ed.vim.last_insert_pos = Some(ed.cursor());
2390    if col > 0 {
2391        crate::motions::move_left(&mut ed.buffer, 1);
2392        ed.push_buffer_cursor_to_textarea();
2393    }
2394    ed.sticky_col = Some(ed.cursor().1);
2395    true
2396}
2397
2398// ─── Phase 6.2: normal-mode primitive bridges ──────────────────────────────
2399
2400/// Scroll direction for `scroll_full_page`, `scroll_half_page`, and
2401/// `scroll_line` controller methods.
2402#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2403pub enum ScrollDir {
2404    /// Move forward / downward (toward end of buffer).
2405    Down,
2406    /// Move backward / upward (toward start of buffer).
2407    Up,
2408}
2409
2410// ── Insert-mode entry bridges ──────────────────────────────────────────────
2411
2412/// `i` — begin Insert at the cursor. `count` is stored in the session for
2413/// insert-exit replay. Returns `true`.
2414pub(crate) fn enter_insert_i_bridge<H: crate::types::Host>(
2415    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2416    count: usize,
2417) {
2418    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::I));
2419}
2420
2421/// `I` — move to first non-blank then begin Insert. `count` stored for replay.
2422pub(crate) fn enter_insert_shift_i_bridge<H: crate::types::Host>(
2423    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2424    count: usize,
2425) {
2426    move_first_non_whitespace(ed);
2427    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftI));
2428}
2429
2430/// `a` — advance past the cursor char then begin Insert. `count` for replay.
2431pub(crate) fn enter_insert_a_bridge<H: crate::types::Host>(
2432    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2433    count: usize,
2434) {
2435    crate::motions::move_right_to_end(&mut ed.buffer, 1);
2436    ed.push_buffer_cursor_to_textarea();
2437    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::A));
2438}
2439
2440/// `A` — move to end-of-line then begin Insert. `count` for replay.
2441pub(crate) fn enter_insert_shift_a_bridge<H: crate::types::Host>(
2442    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2443    count: usize,
2444) {
2445    crate::motions::move_line_end(&mut ed.buffer);
2446    crate::motions::move_right_to_end(&mut ed.buffer, 1);
2447    ed.push_buffer_cursor_to_textarea();
2448    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftA));
2449}
2450
2451/// `o` — open a new line below the cursor and begin Insert.
2452/// When `formatoptions` has `o` and the current line is a comment, the
2453/// continuation prefix is inserted automatically.
2454pub(crate) fn open_line_below_bridge<H: crate::types::Host>(
2455    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2456    count: usize,
2457) {
2458    use hjkl_buffer::{Edit, Position};
2459    ed.push_undo();
2460    begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: false });
2461    ed.sync_buffer_content_from_textarea();
2462    let row = buf_cursor_pos(&ed.buffer).row;
2463    let line_chars = buf_line_chars(&ed.buffer, row);
2464    let prev_line = buf_line(&ed.buffer, row).unwrap_or_default();
2465
2466    // formatoptions `o`: continue comment on open-below.
2467    let comment_cont = if ed.settings.formatoptions.contains('o') {
2468        continue_comment(&ed.buffer, &ed.settings, row)
2469    } else {
2470        None
2471    };
2472
2473    let suffix = if let Some(cont) = comment_cont {
2474        format!("\n{cont}")
2475    } else {
2476        let indent = compute_enter_indent(&ed.settings, &prev_line);
2477        format!("\n{indent}")
2478    };
2479    ed.mutate_edit(Edit::InsertStr {
2480        at: Position::new(row, line_chars),
2481        text: suffix,
2482    });
2483    ed.push_buffer_cursor_to_textarea();
2484}
2485
2486/// `O` — open a new line above the cursor and begin Insert.
2487/// When `formatoptions` has `o` and the current line is a comment, the
2488/// continuation prefix is inserted automatically on the new line above.
2489pub(crate) fn open_line_above_bridge<H: crate::types::Host>(
2490    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2491    count: usize,
2492) {
2493    use hjkl_buffer::{Edit, Position};
2494    ed.push_undo();
2495    begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: true });
2496    ed.sync_buffer_content_from_textarea();
2497    let row = buf_cursor_pos(&ed.buffer).row;
2498
2499    // formatoptions `o`: continue comment on open-above (current line drives).
2500    let comment_cont = if ed.settings.formatoptions.contains('o') {
2501        continue_comment(&ed.buffer, &ed.settings, row)
2502    } else {
2503        None
2504    };
2505
2506    // `new_line_content` is the text of the new line (without the trailing `\n`).
2507    // Used to position the cursor at the end of that content after the move.
2508    let (insert_text, new_line_content) = if let Some(cont) = comment_cont {
2509        let content = cont.clone();
2510        (format!("{cont}\n"), content)
2511    } else {
2512        let indent = if row > 0 {
2513            let above = buf_line(&ed.buffer, row - 1).unwrap_or_default();
2514            compute_enter_indent(&ed.settings, &above)
2515        } else {
2516            let cur = buf_line(&ed.buffer, row).unwrap_or_default();
2517            cur.chars()
2518                .take_while(|c| *c == ' ' || *c == '\t')
2519                .collect::<String>()
2520        };
2521        let content = indent.clone();
2522        (format!("{indent}\n"), content)
2523    };
2524    ed.mutate_edit(Edit::InsertStr {
2525        at: Position::new(row, 0),
2526        text: insert_text,
2527    });
2528    let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2529    crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
2530    let new_row = buf_cursor_pos(&ed.buffer).row;
2531    buf_set_cursor_rc(&mut ed.buffer, new_row, new_line_content.chars().count());
2532    ed.push_buffer_cursor_to_textarea();
2533}
2534
2535/// `R` — enter Replace mode (overstrike). `count` stored for replay.
2536pub(crate) fn enter_replace_mode_bridge<H: crate::types::Host>(
2537    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2538    count: usize,
2539) {
2540    begin_insert(ed, count.max(1), InsertReason::Replace);
2541}
2542
2543// ── Char / line ops ────────────────────────────────────────────────────────
2544
2545/// `x` — delete `count` chars forward from the cursor, writing to the unnamed
2546/// register. Records `LastChange::CharDel` for dot-repeat.
2547pub(crate) fn delete_char_forward_bridge<H: crate::types::Host>(
2548    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2549    count: usize,
2550) {
2551    do_char_delete(ed, true, count.max(1));
2552    if !ed.vim.replaying {
2553        ed.vim.last_change = Some(LastChange::CharDel {
2554            forward: true,
2555            count: count.max(1),
2556        });
2557    }
2558}
2559
2560/// `X` — delete `count` chars backward from the cursor, writing to the unnamed
2561/// register. Records `LastChange::CharDel` for dot-repeat.
2562pub(crate) fn delete_char_backward_bridge<H: crate::types::Host>(
2563    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2564    count: usize,
2565) {
2566    do_char_delete(ed, false, count.max(1));
2567    if !ed.vim.replaying {
2568        ed.vim.last_change = Some(LastChange::CharDel {
2569            forward: false,
2570            count: count.max(1),
2571        });
2572    }
2573}
2574
2575/// `s` — substitute `count` chars (delete then enter Insert). Equivalent to
2576/// `cl`. Records `LastChange::OpMotion` for dot-repeat.
2577pub(crate) fn substitute_char_bridge<H: crate::types::Host>(
2578    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2579    count: usize,
2580) {
2581    use hjkl_buffer::{Edit, MotionKind, Position};
2582    ed.push_undo();
2583    ed.sync_buffer_content_from_textarea();
2584    for _ in 0..count.max(1) {
2585        let cursor = buf_cursor_pos(&ed.buffer);
2586        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
2587        if cursor.col >= line_chars {
2588            break;
2589        }
2590        ed.mutate_edit(Edit::DeleteRange {
2591            start: cursor,
2592            end: Position::new(cursor.row, cursor.col + 1),
2593            kind: MotionKind::Char,
2594        });
2595    }
2596    ed.push_buffer_cursor_to_textarea();
2597    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
2598    if !ed.vim.replaying {
2599        ed.vim.last_change = Some(LastChange::OpMotion {
2600            op: Operator::Change,
2601            motion: Motion::Right,
2602            count: count.max(1),
2603            inserted: None,
2604        });
2605    }
2606}
2607
2608/// `S` — substitute the whole line (delete line contents then enter Insert).
2609/// Equivalent to `cc`. Records `LastChange::LineOp` for dot-repeat.
2610pub(crate) fn substitute_line_bridge<H: crate::types::Host>(
2611    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2612    count: usize,
2613) {
2614    execute_line_op(ed, Operator::Change, count.max(1));
2615    if !ed.vim.replaying {
2616        ed.vim.last_change = Some(LastChange::LineOp {
2617            op: Operator::Change,
2618            count: count.max(1),
2619            inserted: None,
2620        });
2621    }
2622}
2623
2624/// `D` — delete from the cursor to end-of-line, writing to the unnamed
2625/// register. Cursor parks on the new last char. Records for dot-repeat.
2626pub(crate) fn delete_to_eol_bridge<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2627    ed.push_undo();
2628    delete_to_eol(ed);
2629    crate::motions::move_left(&mut ed.buffer, 1);
2630    ed.push_buffer_cursor_to_textarea();
2631    if !ed.vim.replaying {
2632        ed.vim.last_change = Some(LastChange::DeleteToEol { inserted: None });
2633    }
2634}
2635
2636/// `C` — change from the cursor to end-of-line (delete then enter Insert).
2637/// Equivalent to `c$`. Shares the delete path with `D`.
2638pub(crate) fn change_to_eol_bridge<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2639    ed.push_undo();
2640    delete_to_eol(ed);
2641    begin_insert_noundo(ed, 1, InsertReason::DeleteToEol);
2642}
2643
2644/// `Y` — yank from the cursor to end-of-line (same as `y$` in Vim 8 default).
2645pub(crate) fn yank_to_eol_bridge<H: crate::types::Host>(
2646    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2647    count: usize,
2648) {
2649    apply_op_with_motion(ed, Operator::Yank, &Motion::LineEnd, count.max(1));
2650}
2651
2652/// `J` — join `count` lines (default 2) onto the current one, inserting a
2653/// single space between each pair (vim semantics). Records for dot-repeat.
2654pub(crate) fn join_line_bridge<H: crate::types::Host>(
2655    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2656    count: usize,
2657) {
2658    for _ in 0..count.max(1) {
2659        ed.push_undo();
2660        join_line(ed);
2661    }
2662    if !ed.vim.replaying {
2663        ed.vim.last_change = Some(LastChange::JoinLine {
2664            count: count.max(1),
2665        });
2666    }
2667}
2668
2669/// `~` — toggle the case of `count` chars from the cursor, advancing right.
2670/// Records `LastChange::ToggleCase` for dot-repeat.
2671pub(crate) fn toggle_case_at_cursor_bridge<H: crate::types::Host>(
2672    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2673    count: usize,
2674) {
2675    for _ in 0..count.max(1) {
2676        ed.push_undo();
2677        toggle_case_at_cursor(ed);
2678    }
2679    if !ed.vim.replaying {
2680        ed.vim.last_change = Some(LastChange::ToggleCase {
2681            count: count.max(1),
2682        });
2683    }
2684}
2685
2686/// `p` — paste the unnamed register (or `"reg` register) after the cursor.
2687/// Linewise yanks open a new line below; charwise pastes inline.
2688/// Records `LastChange::Paste` for dot-repeat.
2689pub(crate) fn paste_after_bridge<H: crate::types::Host>(
2690    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2691    count: usize,
2692) {
2693    do_paste(ed, false, count.max(1));
2694    if !ed.vim.replaying {
2695        ed.vim.last_change = Some(LastChange::Paste {
2696            before: false,
2697            count: count.max(1),
2698        });
2699    }
2700}
2701
2702/// `P` — paste the unnamed register (or `"reg` register) before the cursor.
2703/// Linewise yanks open a new line above; charwise pastes inline.
2704/// Records `LastChange::Paste` for dot-repeat.
2705pub(crate) fn paste_before_bridge<H: crate::types::Host>(
2706    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2707    count: usize,
2708) {
2709    do_paste(ed, true, count.max(1));
2710    if !ed.vim.replaying {
2711        ed.vim.last_change = Some(LastChange::Paste {
2712            before: true,
2713            count: count.max(1),
2714        });
2715    }
2716}
2717
2718// ── Jump bridges ───────────────────────────────────────────────────────────
2719
2720/// `<C-o>` — jump back `count` entries in the jumplist, saving the current
2721/// position on the forward stack so `<C-i>` can return.
2722pub(crate) fn jump_back_bridge<H: crate::types::Host>(
2723    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2724    count: usize,
2725) {
2726    for _ in 0..count.max(1) {
2727        jump_back(ed);
2728    }
2729}
2730
2731/// `<C-i>` / `Tab` — redo `count` jumps on the forward stack, saving the
2732/// current position on the backward stack.
2733pub(crate) fn jump_forward_bridge<H: crate::types::Host>(
2734    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2735    count: usize,
2736) {
2737    for _ in 0..count.max(1) {
2738        jump_forward(ed);
2739    }
2740}
2741
2742// ── Scroll bridges ─────────────────────────────────────────────────────────
2743
2744/// `<C-f>` / `<C-b>` — scroll the cursor by one full viewport height
2745/// (`h - 2` rows to preserve two-line overlap). `count` multiplies.
2746pub(crate) fn scroll_full_page_bridge<H: crate::types::Host>(
2747    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2748    dir: ScrollDir,
2749    count: usize,
2750) {
2751    let rows = viewport_full_rows(ed, count) as isize;
2752    match dir {
2753        ScrollDir::Down => scroll_cursor_rows(ed, rows),
2754        ScrollDir::Up => scroll_cursor_rows(ed, -rows),
2755    }
2756}
2757
2758/// `<C-d>` / `<C-u>` — scroll the cursor by half the viewport height.
2759/// `count` multiplies.
2760pub(crate) fn scroll_half_page_bridge<H: crate::types::Host>(
2761    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2762    dir: ScrollDir,
2763    count: usize,
2764) {
2765    let rows = viewport_half_rows(ed, count) as isize;
2766    match dir {
2767        ScrollDir::Down => scroll_cursor_rows(ed, rows),
2768        ScrollDir::Up => scroll_cursor_rows(ed, -rows),
2769    }
2770}
2771
2772/// `<C-e>` / `<C-y>` — scroll the viewport `count` lines without moving the
2773/// cursor (cursor is clamped to the new visible region if it would go
2774/// off-screen). `<C-e>` scrolls down; `<C-y>` scrolls up.
2775pub(crate) fn scroll_line_bridge<H: crate::types::Host>(
2776    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2777    dir: ScrollDir,
2778    count: usize,
2779) {
2780    let n = count.max(1);
2781    let total = buf_row_count(&ed.buffer);
2782    let last = total.saturating_sub(1);
2783    let h = ed.viewport_height_value() as usize;
2784    let vp = ed.host().viewport();
2785    let cur_top = vp.top_row;
2786    let new_top = match dir {
2787        ScrollDir::Down => (cur_top + n).min(last),
2788        ScrollDir::Up => cur_top.saturating_sub(n),
2789    };
2790    ed.set_viewport_top(new_top);
2791    // Clamp cursor to stay within the new visible region.
2792    let (row, col) = ed.cursor();
2793    let bot = (new_top + h).saturating_sub(1).min(last);
2794    let clamped = row.max(new_top).min(bot);
2795    if clamped != row {
2796        buf_set_cursor_rc(&mut ed.buffer, clamped, col);
2797        ed.push_buffer_cursor_to_textarea();
2798    }
2799}
2800
2801// ── Search bridges ─────────────────────────────────────────────────────────
2802
2803/// `n` / `N` — repeat the last search `count` times. `forward = true` means
2804/// repeat in the original search direction; `false` inverts it (like `N`).
2805pub(crate) fn search_repeat_bridge<H: crate::types::Host>(
2806    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2807    forward: bool,
2808    count: usize,
2809) {
2810    if let Some(pattern) = ed.vim.last_search.clone() {
2811        ed.push_search_pattern(&pattern);
2812    }
2813    if ed.search_state().pattern.is_none() {
2814        return;
2815    }
2816    let go_forward = ed.vim.last_search_forward == forward;
2817    for _ in 0..count.max(1) {
2818        if go_forward {
2819            ed.search_advance_forward(true);
2820        } else {
2821            ed.search_advance_backward(true);
2822        }
2823    }
2824    ed.push_buffer_cursor_to_textarea();
2825}
2826
2827/// `*` / `#` / `g*` / `g#` — search for the word under the cursor.
2828/// `forward` picks search direction; `whole_word` wraps in `\b...\b`.
2829/// `count` repeats the advance.
2830pub(crate) fn word_search_bridge<H: crate::types::Host>(
2831    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2832    forward: bool,
2833    whole_word: bool,
2834    count: usize,
2835) {
2836    word_at_cursor_search(ed, forward, whole_word, count.max(1));
2837}
2838
2839// ── Undo / redo confirmation wrappers (already public on Editor) ───────────
2840
2841/// `u` bridge — identical to `do_undo`; retained for Phase 6.6b audit.
2842/// The FSM now calls `ed.undo()` directly (Phase 6.6a).
2843#[allow(dead_code)]
2844#[inline]
2845pub(crate) fn do_undo_bridge<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2846    do_undo(ed);
2847}
2848
2849// ─── Phase 6.3: visual-mode primitive bridges ──────────────────────────────
2850//
2851// Each `pub(crate)` free function is the extractable body of one visual-mode
2852// transition. These bridges set `vim.mode` directly AND write `current_mode`
2853// so that `Editor::vim_mode()` can read from the stable field without going
2854// through `public_mode()`.
2855//
2856// Pattern identical to Phase 6.1 / 6.2:
2857//   - Bridge fn is `pub(crate) fn *_bridge<H: Host>(ed, …)` in this file.
2858//   - Public wrapper is `pub fn *(&mut self, …)` in `editor.rs` with rustdoc.
2859
2860/// Helper — set both the FSM-internal `mode` and the stable `current_mode`
2861/// field in one call. Every Phase 6.3 bridge that changes mode calls this so
2862/// `vim_mode()` stays correct without going through the FSM's `step()` loop.
2863#[inline]
2864pub(crate) fn set_vim_mode_bridge<H: crate::types::Host>(
2865    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2866    mode: Mode,
2867) {
2868    ed.vim.mode = mode;
2869    ed.vim.current_mode = ed.vim.public_mode();
2870}
2871
2872/// `v` from Normal — enter charwise Visual mode. Anchors at the current
2873/// cursor position; the cursor IS the live end of the selection.
2874pub(crate) fn enter_visual_char_bridge<H: crate::types::Host>(
2875    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2876) {
2877    let cur = ed.cursor();
2878    ed.vim.visual_anchor = cur;
2879    set_vim_mode_bridge(ed, Mode::Visual);
2880}
2881
2882/// `V` from Normal — enter linewise Visual mode. Anchors the whole line
2883/// containing the current cursor; `o` still swaps the anchor row.
2884pub(crate) fn enter_visual_line_bridge<H: crate::types::Host>(
2885    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2886) {
2887    let (row, _) = ed.cursor();
2888    ed.vim.visual_line_anchor = row;
2889    set_vim_mode_bridge(ed, Mode::VisualLine);
2890}
2891
2892/// `<C-v>` from Normal — enter Visual-block mode. Anchors at the current
2893/// cursor; `block_vcol` is seeded from the cursor column so h/l navigation
2894/// preserves the desired virtual column.
2895pub(crate) fn enter_visual_block_bridge<H: crate::types::Host>(
2896    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2897) {
2898    let cur = ed.cursor();
2899    ed.vim.block_anchor = cur;
2900    ed.vim.block_vcol = cur.1;
2901    set_vim_mode_bridge(ed, Mode::VisualBlock);
2902}
2903
2904/// Esc from any visual mode — set `<` / `>` marks (per `:h v_:`), stash the
2905/// selection for `gv` re-entry, and return to Normal. Replicates the
2906/// `pre_visual_snapshot` logic in `step()` so callers outside the FSM get
2907/// identical behaviour.
2908pub(crate) fn exit_visual_to_normal_bridge<H: crate::types::Host>(
2909    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2910) {
2911    // Build the same snapshot that `step()` captures at pre-step time.
2912    let snap: Option<LastVisual> = match ed.vim.mode {
2913        Mode::Visual => Some(LastVisual {
2914            mode: Mode::Visual,
2915            anchor: ed.vim.visual_anchor,
2916            cursor: ed.cursor(),
2917            block_vcol: 0,
2918        }),
2919        Mode::VisualLine => Some(LastVisual {
2920            mode: Mode::VisualLine,
2921            anchor: (ed.vim.visual_line_anchor, 0),
2922            cursor: ed.cursor(),
2923            block_vcol: 0,
2924        }),
2925        Mode::VisualBlock => Some(LastVisual {
2926            mode: Mode::VisualBlock,
2927            anchor: ed.vim.block_anchor,
2928            cursor: ed.cursor(),
2929            block_vcol: ed.vim.block_vcol,
2930        }),
2931        _ => None,
2932    };
2933    // Transition to Normal first (matches FSM order).
2934    ed.vim.pending = Pending::None;
2935    ed.vim.count = 0;
2936    ed.vim.insert_session = None;
2937    set_vim_mode_bridge(ed, Mode::Normal);
2938    // Set `<` / `>` marks and stash `last_visual` — mirrors the post-step
2939    // logic in `step()` that fires when a visual → non-visual transition
2940    // is detected.
2941    if let Some(snap) = snap {
2942        let (lo, hi) = match snap.mode {
2943            Mode::Visual => {
2944                if snap.anchor <= snap.cursor {
2945                    (snap.anchor, snap.cursor)
2946                } else {
2947                    (snap.cursor, snap.anchor)
2948                }
2949            }
2950            Mode::VisualLine => {
2951                let r_lo = snap.anchor.0.min(snap.cursor.0);
2952                let r_hi = snap.anchor.0.max(snap.cursor.0);
2953                let vl_rope = ed.buffer().rope();
2954                let r_hi_clamped = r_hi.min(vl_rope.len_lines().saturating_sub(1));
2955                let last_col = hjkl_buffer::rope_line_str(&vl_rope, r_hi_clamped)
2956                    .chars()
2957                    .count()
2958                    .saturating_sub(1);
2959                ((r_lo, 0), (r_hi, last_col))
2960            }
2961            Mode::VisualBlock => {
2962                let (r1, c1) = snap.anchor;
2963                let (r2, c2) = snap.cursor;
2964                ((r1.min(r2), c1.min(c2)), (r1.max(r2), c1.max(c2)))
2965            }
2966            _ => {
2967                if snap.anchor <= snap.cursor {
2968                    (snap.anchor, snap.cursor)
2969                } else {
2970                    (snap.cursor, snap.anchor)
2971                }
2972            }
2973        };
2974        ed.set_mark('<', lo);
2975        ed.set_mark('>', hi);
2976        ed.vim.last_visual = Some(snap);
2977    }
2978}
2979
2980/// `o` in Visual / VisualLine / VisualBlock — swap the cursor and anchor
2981/// without mutating the selection range. In charwise mode the cursor jumps
2982/// to the old anchor and the anchor takes the old cursor. In linewise mode
2983/// the anchor *row* swaps with the current cursor row. In block mode the
2984/// block corners swap.
2985pub(crate) fn visual_o_toggle_bridge<H: crate::types::Host>(
2986    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2987) {
2988    match ed.vim.mode {
2989        Mode::Visual => {
2990            let cur = ed.cursor();
2991            let anchor = ed.vim.visual_anchor;
2992            ed.vim.visual_anchor = cur;
2993            ed.jump_cursor(anchor.0, anchor.1);
2994        }
2995        Mode::VisualLine => {
2996            let cur_row = ed.cursor().0;
2997            let anchor_row = ed.vim.visual_line_anchor;
2998            ed.vim.visual_line_anchor = cur_row;
2999            ed.jump_cursor(anchor_row, 0);
3000        }
3001        Mode::VisualBlock => {
3002            let cur = ed.cursor();
3003            let anchor = ed.vim.block_anchor;
3004            ed.vim.block_anchor = cur;
3005            ed.vim.block_vcol = anchor.1;
3006            ed.jump_cursor(anchor.0, anchor.1);
3007        }
3008        _ => {}
3009    }
3010}
3011
3012/// `gv` — restore the last visual selection (mode + anchor + cursor).
3013/// No-op if no selection was ever stored. Mirrors the `gv` arm in
3014/// `handle_normal_g`.
3015pub(crate) fn reenter_last_visual_bridge<H: crate::types::Host>(
3016    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3017) {
3018    if let Some(snap) = ed.vim.last_visual {
3019        match snap.mode {
3020            Mode::Visual => {
3021                ed.vim.visual_anchor = snap.anchor;
3022                set_vim_mode_bridge(ed, Mode::Visual);
3023            }
3024            Mode::VisualLine => {
3025                ed.vim.visual_line_anchor = snap.anchor.0;
3026                set_vim_mode_bridge(ed, Mode::VisualLine);
3027            }
3028            Mode::VisualBlock => {
3029                ed.vim.block_anchor = snap.anchor;
3030                ed.vim.block_vcol = snap.block_vcol;
3031                set_vim_mode_bridge(ed, Mode::VisualBlock);
3032            }
3033            _ => {}
3034        }
3035        ed.jump_cursor(snap.cursor.0, snap.cursor.1);
3036    }
3037}
3038
3039/// Direct mode-transition entry point for external controllers (e.g.
3040/// hjkl-vim). Sets both the FSM-internal `mode` and the stable
3041/// `current_mode`. Use sparingly — prefer the semantic primitives
3042/// (`enter_visual_char_bridge`, `enter_insert_i_bridge`, …) which also
3043/// set up the required bookkeeping (anchors, sessions, …).
3044pub(crate) fn set_mode_bridge<H: crate::types::Host>(
3045    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3046    mode: crate::VimMode,
3047) {
3048    let internal = match mode {
3049        crate::VimMode::Normal => Mode::Normal,
3050        crate::VimMode::Insert => Mode::Insert,
3051        crate::VimMode::Visual => Mode::Visual,
3052        crate::VimMode::VisualLine => Mode::VisualLine,
3053        crate::VimMode::VisualBlock => Mode::VisualBlock,
3054    };
3055    ed.vim.mode = internal;
3056    ed.vim.current_mode = mode;
3057}
3058
3059// ─── Normal / Visual / Operator-pending dispatcher removed in Phase 6.6g.3 ──
3060//
3061// `step_normal` and all private dispatch helpers (handle_after_op,
3062// handle_after_g, handle_after_z, handle_normal_only, etc.) were deleted.
3063// The canonical FSM body lives in `hjkl-vim::normal`. Use
3064// `hjkl_vim::dispatch_input` as the entry point.
3065//
3066// DELETED FUNCTION SIGNATURE (for archaeology):
3067// pub(crate) fn step_normal<H: crate::types::Host>(ed: ..., input: Input) -> bool {
3068
3069/// `m{ch}` — public controller entry point. Validates `ch` (must be
3070/// alphanumeric to match vim's mark-name rules) and records the current
3071/// cursor position under that name. Promoted to the public surface in 0.6.7
3072/// so the hjkl-vim `PendingState::SetMark` reducer can dispatch
3073/// `EngineCmd::SetMark` without re-entering the engine FSM.
3074/// `handle_set_mark` delegates here to avoid logic duplication.
3075pub(crate) fn set_mark_at_cursor<H: crate::types::Host>(
3076    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3077    ch: char,
3078) {
3079    if ch.is_ascii_lowercase() {
3080        let pos = ed.cursor();
3081        ed.set_mark(ch, pos);
3082    } else if ch.is_ascii_uppercase() {
3083        let pos = ed.cursor();
3084        let bid = ed.current_buffer_id();
3085        ed.set_global_mark(ch, bid, pos);
3086        tracing::debug!(
3087            mark = ch as u32,
3088            buffer_id = bid,
3089            row = pos.0,
3090            col = pos.1,
3091            "global mark set"
3092        );
3093    }
3094    // Invalid chars silently no-op (mirrors handle_set_mark behaviour).
3095}
3096
3097/// `'<ch>` / `` `<ch> `` — public controller entry point for lowercase and
3098/// special marks. Validates `ch` against the set of legal mark names
3099/// (lowercase, special: `'`/`` ` ``/`.`/`[`/`]`/`<`/`>`), resolves the
3100/// target position, and jumps the cursor. `linewise = true` → row only, col
3101/// snaps to first non-blank; `linewise = false` → exact (row, col).
3102///
3103/// Uppercase marks are handled by [`try_goto_mark`] which can return a
3104/// `MarkJump::CrossBuffer` for cross-buffer jumps.
3105pub(crate) fn goto_mark<H: crate::types::Host>(
3106    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3107    ch: char,
3108    linewise: bool,
3109) {
3110    let target = match ch {
3111        'a'..='z' => ed.mark(ch),
3112        '\'' | '`' => ed.vim.jump_back.last().copied(),
3113        '.' => ed.vim.last_edit_pos,
3114        '[' | ']' | '<' | '>' => ed.mark(ch),
3115        _ => None,
3116    };
3117    let Some((row, col)) = target else {
3118        return;
3119    };
3120    let pre = ed.cursor();
3121    let (r, c_clamped) = clamp_pos(ed, (row, col));
3122    if linewise {
3123        buf_set_cursor_rc(&mut ed.buffer, r, 0);
3124        ed.push_buffer_cursor_to_textarea();
3125        move_first_non_whitespace(ed);
3126    } else {
3127        buf_set_cursor_rc(&mut ed.buffer, r, c_clamped);
3128        ed.push_buffer_cursor_to_textarea();
3129    }
3130    if ed.cursor() != pre {
3131        ed.push_jump(pre);
3132    }
3133    ed.sticky_col = Some(ed.cursor().1);
3134}
3135
3136/// Unified mark-jump entry point that returns a [`crate::editor::MarkJump`]
3137/// so the app layer can decide whether to switch buffers.
3138///
3139/// - Uppercase marks (`'A'`–`'Z'`) look in `global_marks`. If the stored
3140///   `buffer_id` differs from `ed.current_buffer_id()`, returns
3141///   `CrossBuffer`. Same-buffer uppercase marks execute the jump normally.
3142/// - All other legal mark chars delegate to [`goto_mark`] and return
3143///   `SameBuffer`.
3144pub(crate) fn try_goto_mark<H: crate::types::Host>(
3145    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3146    ch: char,
3147    linewise: bool,
3148) -> crate::editor::MarkJump {
3149    use crate::editor::MarkJump;
3150    match ch {
3151        'A'..='Z' => {
3152            let Some((bid, row, col)) = ed.global_mark(ch) else {
3153                return MarkJump::Unset;
3154            };
3155            if bid != ed.current_buffer_id() {
3156                tracing::debug!(
3157                    mark = ch as u32,
3158                    buffer_id = bid,
3159                    row,
3160                    col,
3161                    "global mark cross-buffer jump"
3162                );
3163                return MarkJump::CrossBuffer {
3164                    buffer_id: bid,
3165                    row,
3166                    col,
3167                };
3168            }
3169            // Same buffer — execute the jump normally.
3170            let pre = ed.cursor();
3171            let (r, c_clamped) = clamp_pos(ed, (row, col));
3172            if linewise {
3173                buf_set_cursor_rc(&mut ed.buffer, r, 0);
3174                ed.push_buffer_cursor_to_textarea();
3175                move_first_non_whitespace(ed);
3176            } else {
3177                buf_set_cursor_rc(&mut ed.buffer, r, c_clamped);
3178                ed.push_buffer_cursor_to_textarea();
3179            }
3180            if ed.cursor() != pre {
3181                ed.push_jump(pre);
3182            }
3183            ed.sticky_col = Some(ed.cursor().1);
3184            MarkJump::SameBuffer
3185        }
3186        'a'..='z' | '\'' | '`' | '.' | '[' | ']' | '<' | '>' => {
3187            goto_mark(ed, ch, linewise);
3188            MarkJump::SameBuffer
3189        }
3190        _ => MarkJump::Unset,
3191    }
3192}
3193
3194/// `true` when `op` records a `last_change` entry for dot-repeat purposes.
3195/// Promoted to `pub` in Phase 6.6e so `hjkl-vim::normal` can use it without
3196/// duplicating the logic.
3197pub fn op_is_change(op: Operator) -> bool {
3198    matches!(op, Operator::Delete | Operator::Change)
3199}
3200
3201// ─── Jumplist (Ctrl-o / Ctrl-i) ────────────────────────────────────────────
3202
3203/// Max jumplist depth. Matches vim default.
3204pub(crate) const JUMPLIST_MAX: usize = 100;
3205
3206/// `Ctrl-o` — jump back to the most recent pre-jump position. Saves
3207/// the current cursor onto the forward stack so `Ctrl-i` can return.
3208fn jump_back<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
3209    let Some(target) = ed.vim.jump_back.pop() else {
3210        return;
3211    };
3212    let cur = ed.cursor();
3213    ed.vim.jump_fwd.push(cur);
3214    let (r, c) = clamp_pos(ed, target);
3215    ed.jump_cursor(r, c);
3216    ed.sticky_col = Some(c);
3217}
3218
3219/// `Ctrl-i` / `Tab` — redo the last `Ctrl-o`. Saves the current cursor
3220/// onto the back stack.
3221fn jump_forward<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
3222    let Some(target) = ed.vim.jump_fwd.pop() else {
3223        return;
3224    };
3225    let cur = ed.cursor();
3226    ed.vim.jump_back.push(cur);
3227    if ed.vim.jump_back.len() > JUMPLIST_MAX {
3228        ed.vim.jump_back.remove(0);
3229    }
3230    let (r, c) = clamp_pos(ed, target);
3231    ed.jump_cursor(r, c);
3232    ed.sticky_col = Some(c);
3233}
3234
3235/// Clamp a stored `(row, col)` to the live buffer in case edits
3236/// shrunk the document between push and pop.
3237fn clamp_pos<H: crate::types::Host>(
3238    ed: &Editor<hjkl_buffer::Buffer, H>,
3239    pos: (usize, usize),
3240) -> (usize, usize) {
3241    let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
3242    let r = pos.0.min(last_row);
3243    let line_len = buf_line_chars(&ed.buffer, r);
3244    let c = pos.1.min(line_len.saturating_sub(1));
3245    (r, c)
3246}
3247
3248/// True for motions that vim treats as jumps (pushed onto the jumplist).
3249fn is_big_jump(motion: &Motion) -> bool {
3250    matches!(
3251        motion,
3252        Motion::FileTop
3253            | Motion::FileBottom
3254            | Motion::MatchBracket
3255            | Motion::WordAtCursor { .. }
3256            | Motion::SearchNext { .. }
3257            | Motion::ViewportTop
3258            | Motion::ViewportMiddle
3259            | Motion::ViewportBottom
3260    )
3261}
3262
3263// ─── Scroll helpers (Ctrl-d / Ctrl-u / Ctrl-f / Ctrl-b) ────────────────────
3264
3265/// Half-viewport row count, with a floor of 1 so tiny / un-rendered
3266/// viewports still step by a single row. `count` multiplies.
3267fn viewport_half_rows<H: crate::types::Host>(
3268    ed: &Editor<hjkl_buffer::Buffer, H>,
3269    count: usize,
3270) -> usize {
3271    let h = ed.viewport_height_value() as usize;
3272    (h / 2).max(1).saturating_mul(count.max(1))
3273}
3274
3275/// Full-viewport row count. Vim conventionally keeps 2 lines of overlap
3276/// between successive `Ctrl-f` pages; we approximate with `h - 2`.
3277fn viewport_full_rows<H: crate::types::Host>(
3278    ed: &Editor<hjkl_buffer::Buffer, H>,
3279    count: usize,
3280) -> usize {
3281    let h = ed.viewport_height_value() as usize;
3282    h.saturating_sub(2).max(1).saturating_mul(count.max(1))
3283}
3284
3285/// Move the cursor by `delta` rows (positive = down, negative = up),
3286/// clamp to the document, then land at the first non-blank on the new
3287/// row. The textarea viewport auto-scrolls to keep the cursor visible
3288/// when the cursor pushes off-screen.
3289fn scroll_cursor_rows<H: crate::types::Host>(
3290    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3291    delta: isize,
3292) {
3293    if delta == 0 {
3294        return;
3295    }
3296    ed.sync_buffer_content_from_textarea();
3297    let (row, _) = ed.cursor();
3298    let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
3299    let target = (row as isize + delta).max(0).min(last_row as isize) as usize;
3300    buf_set_cursor_rc(&mut ed.buffer, target, 0);
3301    crate::motions::move_first_non_blank(&mut ed.buffer);
3302    ed.push_buffer_cursor_to_textarea();
3303    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
3304}
3305
3306// ─── Motion parsing ────────────────────────────────────────────────────────
3307
3308/// Parse the first key of a normal/visual-mode motion. Returns `None` for
3309/// keys that don't start a motion (operator keys, command keys, etc.).
3310/// Promoted to `pub` in Phase 6.6e so `hjkl-vim::normal` can call it.
3311pub fn parse_motion(input: &Input) -> Option<Motion> {
3312    if input.ctrl {
3313        return None;
3314    }
3315    match input.key {
3316        Key::Char('h') | Key::Backspace | Key::Left => Some(Motion::Left),
3317        Key::Char('l') | Key::Right => Some(Motion::Right),
3318        Key::Char('j') | Key::Down => Some(Motion::Down),
3319        // `+` / `<CR>` — first non-blank of next line (linewise, count-aware).
3320        Key::Char('+') | Key::Enter => Some(Motion::FirstNonBlankNextLine),
3321        // `-` — first non-blank of previous line (linewise, count-aware).
3322        Key::Char('-') => Some(Motion::FirstNonBlankPrevLine),
3323        // `_` — first non-blank of current line, or count-1 lines down (linewise).
3324        Key::Char('_') => Some(Motion::FirstNonBlankLine),
3325        Key::Char('k') | Key::Up => Some(Motion::Up),
3326        Key::Char('w') => Some(Motion::WordFwd),
3327        Key::Char('W') => Some(Motion::BigWordFwd),
3328        Key::Char('b') => Some(Motion::WordBack),
3329        Key::Char('B') => Some(Motion::BigWordBack),
3330        Key::Char('e') => Some(Motion::WordEnd),
3331        Key::Char('E') => Some(Motion::BigWordEnd),
3332        Key::Char('0') | Key::Home => Some(Motion::LineStart),
3333        Key::Char('^') => Some(Motion::FirstNonBlank),
3334        Key::Char('$') | Key::End => Some(Motion::LineEnd),
3335        Key::Char('G') => Some(Motion::FileBottom),
3336        Key::Char('%') => Some(Motion::MatchBracket),
3337        Key::Char(';') => Some(Motion::FindRepeat { reverse: false }),
3338        Key::Char(',') => Some(Motion::FindRepeat { reverse: true }),
3339        Key::Char('*') => Some(Motion::WordAtCursor {
3340            forward: true,
3341            whole_word: true,
3342        }),
3343        Key::Char('#') => Some(Motion::WordAtCursor {
3344            forward: false,
3345            whole_word: true,
3346        }),
3347        Key::Char('n') => Some(Motion::SearchNext { reverse: false }),
3348        Key::Char('N') => Some(Motion::SearchNext { reverse: true }),
3349        Key::Char('H') => Some(Motion::ViewportTop),
3350        Key::Char('M') => Some(Motion::ViewportMiddle),
3351        Key::Char('L') => Some(Motion::ViewportBottom),
3352        Key::Char('{') => Some(Motion::ParagraphPrev),
3353        Key::Char('}') => Some(Motion::ParagraphNext),
3354        Key::Char('(') => Some(Motion::SentencePrev),
3355        Key::Char(')') => Some(Motion::SentenceNext),
3356        Key::Char('|') => Some(Motion::GotoColumn),
3357        _ => None,
3358    }
3359}
3360
3361// ─── Motion execution ──────────────────────────────────────────────────────
3362
3363pub(crate) fn execute_motion<H: crate::types::Host>(
3364    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3365    motion: Motion,
3366    count: usize,
3367) {
3368    let count = count.max(1);
3369    // `;`/`,` smart fallback: if the last horizontal motion was a sneak
3370    // digraph, repeat via apply_sneak instead of find-char.
3371    if let Motion::FindRepeat { reverse } = motion
3372        && ed.vim.last_horizontal_motion == LastHorizontalMotion::Sneak
3373    {
3374        if let Some(((c1, c2), fwd)) = ed.vim.last_sneak {
3375            let effective_fwd = if reverse { !fwd } else { fwd };
3376            apply_sneak(ed, c1, c2, effective_fwd, count);
3377        }
3378        return;
3379    }
3380    // FindRepeat needs the stored direction.
3381    let motion = match motion {
3382        Motion::FindRepeat { reverse } => match ed.vim.last_find {
3383            Some((ch, forward, till)) => Motion::Find {
3384                ch,
3385                forward: if reverse { !forward } else { forward },
3386                till,
3387            },
3388            None => return,
3389        },
3390        other => other,
3391    };
3392    let pre_pos = ed.cursor();
3393    let pre_col = pre_pos.1;
3394    apply_motion_cursor(ed, &motion, count);
3395    let post_pos = ed.cursor();
3396    if is_big_jump(&motion) && pre_pos != post_pos {
3397        ed.push_jump(pre_pos);
3398    }
3399    apply_sticky_col(ed, &motion, pre_col);
3400    // Phase 7b: keep the migration buffer's cursor + viewport in
3401    // lockstep with the textarea after every motion. Once 7c lands
3402    // (motions ported onto the buffer's API), this flips: the
3403    // buffer becomes authoritative and the textarea mirrors it.
3404    ed.sync_buffer_from_textarea();
3405}
3406
3407// ─── Keymap-layer motion controller ────────────────────────────────────────
3408
3409/// Wrapper around `execute_motion` that also syncs `block_vcol` when in
3410/// VisualBlock mode. The engine FSM's `step()` already does this (line ~2001);
3411/// the keymap path (`apply_motion_kind`) must do the same so VisualBlock h/l
3412/// extend the highlighted region correctly.
3413///
3414/// `update_block_vcol` is only a no-op for vertical / non-horizontal motions
3415/// (Up, Down, FileTop, FileBottom, Search), so passing every motion through is
3416/// safe — the function's own match arm handles the no-op case.
3417fn execute_motion_with_block_vcol<H: crate::types::Host>(
3418    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3419    motion: Motion,
3420    count: usize,
3421) {
3422    let motion_copy = motion.clone();
3423    execute_motion(ed, motion, count);
3424    if ed.vim.mode == Mode::VisualBlock {
3425        update_block_vcol(ed, &motion_copy);
3426    }
3427}
3428
3429/// Execute a `crate::MotionKind` cursor motion. Called by the host's
3430/// `Editor::apply_motion` controller method — the keymap dispatch path for
3431/// Phase 3a of kryptic-sh/hjkl#69.
3432///
3433/// Maps each variant to the same internal primitives used by the engine FSM
3434/// so cursor, sticky column, scroll, and sync semantics are identical.
3435///
3436/// # Visual-mode post-motion sync audit (2026-05-13)
3437///
3438/// After `execute_motion`, two things are conditional on visual mode:
3439///
3440/// 1. **VisualBlock `block_vcol` sync** — `update_block_vcol(ed, &motion)` is
3441///    called when `mode == Mode::VisualBlock`.  This is replicated here via
3442///    `execute_motion_with_block_vcol` for every motion variant below.
3443///
3444/// 2. **`last_find` update** — `Motion::Find` is dispatched through
3445///    `Pending::Find → apply_find_char` (in hjkl-vim), which writes `last_find`
3446///    itself.  A post-motion `last_find` write here would be dead code.  The keymap
3447///    path writes `last_find` in `apply_find_char` (called from
3448///    `Editor::find_char`), so no gap exists here.
3449///
3450/// No VisualLine-specific or Visual-specific post-motion work exists in the
3451/// FSM: anchors (`visual_anchor`, `visual_line_anchor`, `block_anchor`) are
3452/// only written on mode-entry or `o`-swap, never on motion.  The `<`/`>`
3453/// mark update in `step()` fires only on visual→normal transition, not after
3454/// each motion.  There are **no further sync gaps** beyond the `block_vcol`
3455/// fix already applied above.
3456pub(crate) fn apply_motion_kind<H: crate::types::Host>(
3457    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3458    kind: crate::MotionKind,
3459    count: usize,
3460) {
3461    let count = count.max(1);
3462    match kind {
3463        crate::MotionKind::CharLeft => {
3464            execute_motion_with_block_vcol(ed, Motion::Left, count);
3465        }
3466        crate::MotionKind::CharRight => {
3467            execute_motion_with_block_vcol(ed, Motion::Right, count);
3468        }
3469        crate::MotionKind::LineDown => {
3470            execute_motion_with_block_vcol(ed, Motion::Down, count);
3471        }
3472        crate::MotionKind::LineUp => {
3473            execute_motion_with_block_vcol(ed, Motion::Up, count);
3474        }
3475        crate::MotionKind::FirstNonBlankDown => {
3476            // `+`: move down `count` lines then land on first non-blank.
3477            // Not a big-jump (no jump-list entry), sticky col set to the
3478            // landed column (first non-blank). Mirrors scroll_cursor_rows
3479            // semantics but goes through the fold-aware buffer motion path.
3480            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3481            crate::motions::move_down(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
3482            crate::motions::move_first_non_blank(&mut ed.buffer);
3483            ed.push_buffer_cursor_to_textarea();
3484            ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
3485            ed.sync_buffer_from_textarea();
3486        }
3487        crate::MotionKind::FirstNonBlankUp => {
3488            // `-`: move up `count` lines then land on first non-blank.
3489            // Same pattern as FirstNonBlankDown, direction reversed.
3490            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3491            crate::motions::move_up(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
3492            crate::motions::move_first_non_blank(&mut ed.buffer);
3493            ed.push_buffer_cursor_to_textarea();
3494            ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
3495            ed.sync_buffer_from_textarea();
3496        }
3497        crate::MotionKind::WordForward => {
3498            execute_motion_with_block_vcol(ed, Motion::WordFwd, count);
3499        }
3500        crate::MotionKind::BigWordForward => {
3501            execute_motion_with_block_vcol(ed, Motion::BigWordFwd, count);
3502        }
3503        crate::MotionKind::WordBackward => {
3504            execute_motion_with_block_vcol(ed, Motion::WordBack, count);
3505        }
3506        crate::MotionKind::BigWordBackward => {
3507            execute_motion_with_block_vcol(ed, Motion::BigWordBack, count);
3508        }
3509        crate::MotionKind::WordEnd => {
3510            execute_motion_with_block_vcol(ed, Motion::WordEnd, count);
3511        }
3512        crate::MotionKind::BigWordEnd => {
3513            execute_motion_with_block_vcol(ed, Motion::BigWordEnd, count);
3514        }
3515        crate::MotionKind::LineStart => {
3516            // `0` / `<Home>`: first column of the current line.
3517            // count is ignored — matches vim `0` semantics.
3518            execute_motion_with_block_vcol(ed, Motion::LineStart, 1);
3519        }
3520        crate::MotionKind::FirstNonBlank => {
3521            // `^`: first non-blank column on the current line.
3522            // count is ignored — matches vim `^` semantics.
3523            execute_motion_with_block_vcol(ed, Motion::FirstNonBlank, 1);
3524        }
3525        crate::MotionKind::GotoLine => {
3526            // `G`: bare `G` → last line; `count G` → jump to line `count`.
3527            // apply_motion_kind normalises the raw count to count.max(1)
3528            // above, so count == 1 means "bare G" (last line) and count > 1
3529            // means "go to line N". execute_motion's FileBottom arm applies
3530            // the same `count > 1` check before calling move_bottom, so the
3531            // convention aligns: pass count straight through.
3532            // FileBottom is vertical — update_block_vcol is a no-op here
3533            // (preserves vcol), so the helper is safe to use.
3534            execute_motion_with_block_vcol(ed, Motion::FileBottom, count);
3535        }
3536        crate::MotionKind::LineEnd => {
3537            // `$` / `<End>`: last character on the current line.
3538            // count is ignored at the keymap-path level (vim `N$` moves
3539            // down N-1 lines then lands at line-end; not yet wired).
3540            execute_motion_with_block_vcol(ed, Motion::LineEnd, 1);
3541        }
3542        crate::MotionKind::FindRepeat => {
3543            // `;` — repeat last f/F/t/T in the same direction.
3544            // execute_motion resolves FindRepeat via ed.vim.last_find;
3545            // no-op if no prior find exists (None arm returns early).
3546            execute_motion_with_block_vcol(ed, Motion::FindRepeat { reverse: false }, count);
3547        }
3548        crate::MotionKind::FindRepeatReverse => {
3549            // `,` — repeat last f/F/t/T in the reverse direction.
3550            // execute_motion resolves FindRepeat via ed.vim.last_find;
3551            // no-op if no prior find exists (None arm returns early).
3552            execute_motion_with_block_vcol(ed, Motion::FindRepeat { reverse: true }, count);
3553        }
3554        crate::MotionKind::BracketMatch => {
3555            // `%` — jump to the matching bracket.
3556            // count is passed through; engine-side matching_bracket handles
3557            // the no-match case as a no-op (cursor stays). Engine FSM arm
3558            // for `%` in parse_motion is kept intact for macro-replay.
3559            execute_motion_with_block_vcol(ed, Motion::MatchBracket, count);
3560        }
3561        crate::MotionKind::ViewportTop => {
3562            // `H` — cursor to top of visible viewport, then count-1 rows down.
3563            // Engine FSM arm for `H` in parse_motion is kept intact for macro-replay.
3564            execute_motion_with_block_vcol(ed, Motion::ViewportTop, count);
3565        }
3566        crate::MotionKind::ViewportMiddle => {
3567            // `M` — cursor to middle of visible viewport; count ignored.
3568            // Engine FSM arm for `M` in parse_motion is kept intact for macro-replay.
3569            execute_motion_with_block_vcol(ed, Motion::ViewportMiddle, count);
3570        }
3571        crate::MotionKind::ViewportBottom => {
3572            // `L` — cursor to bottom of visible viewport, then count-1 rows up.
3573            // Engine FSM arm for `L` in parse_motion is kept intact for macro-replay.
3574            execute_motion_with_block_vcol(ed, Motion::ViewportBottom, count);
3575        }
3576        crate::MotionKind::HalfPageDown => {
3577            // `<C-d>` — half page down, count multiplies the distance.
3578            // Calls scroll_cursor_rows directly rather than adding a Motion enum
3579            // variant, keeping engine Motion churn minimal.
3580            scroll_cursor_rows(ed, viewport_half_rows(ed, count) as isize);
3581        }
3582        crate::MotionKind::HalfPageUp => {
3583            // `<C-u>` — half page up, count multiplies the distance.
3584            // Direct call mirrors the FSM Ctrl-u arm. No new Motion variant.
3585            scroll_cursor_rows(ed, -(viewport_half_rows(ed, count) as isize));
3586        }
3587        crate::MotionKind::FullPageDown => {
3588            // `<C-f>` — full page down (2-line overlap), count multiplies.
3589            // Direct call mirrors the FSM Ctrl-f arm. No new Motion variant.
3590            scroll_cursor_rows(ed, viewport_full_rows(ed, count) as isize);
3591        }
3592        crate::MotionKind::FullPageUp => {
3593            // `<C-b>` — full page up (2-line overlap), count multiplies.
3594            // Direct call mirrors the FSM Ctrl-b arm. No new Motion variant.
3595            scroll_cursor_rows(ed, -(viewport_full_rows(ed, count) as isize));
3596        }
3597        crate::MotionKind::FirstNonBlankLine => {
3598            execute_motion_with_block_vcol(ed, Motion::FirstNonBlankLine, count);
3599        }
3600        crate::MotionKind::SectionBackward => {
3601            execute_motion_with_block_vcol(ed, Motion::SectionBackward, count);
3602        }
3603        crate::MotionKind::SectionForward => {
3604            execute_motion_with_block_vcol(ed, Motion::SectionForward, count);
3605        }
3606        crate::MotionKind::SectionEndBackward => {
3607            execute_motion_with_block_vcol(ed, Motion::SectionEndBackward, count);
3608        }
3609        crate::MotionKind::SectionEndForward => {
3610            execute_motion_with_block_vcol(ed, Motion::SectionEndForward, count);
3611        }
3612    }
3613}
3614
3615/// Restore the cursor to the sticky column after vertical motions and
3616/// sync the sticky column to the current column after horizontal ones.
3617/// `pre_col` is the cursor column captured *before* the motion — used
3618/// to bootstrap the sticky value on the very first motion.
3619fn apply_sticky_col<H: crate::types::Host>(
3620    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3621    motion: &Motion,
3622    pre_col: usize,
3623) {
3624    if is_vertical_motion(motion) {
3625        let want = ed.sticky_col.unwrap_or(pre_col);
3626        // Record the desired column so the next vertical motion sees
3627        // it even if we currently clamped to a shorter row.
3628        ed.sticky_col = Some(want);
3629        let (row, _) = ed.cursor();
3630        let line_len = buf_line_chars(&ed.buffer, row);
3631        // Clamp to the last char on non-empty lines (vim normal-mode
3632        // never parks the cursor one past end of line). Empty lines
3633        // collapse to col 0.
3634        let max_col = line_len.saturating_sub(1);
3635        let target = want.min(max_col);
3636        // raw primitive: this function MUST preserve the un-clamped `want`
3637        // already stored in `ed.sticky_col`; `jump_cursor` would overwrite
3638        // it with the clamped `target`.
3639        buf_set_cursor_rc(&mut ed.buffer, row, target);
3640    } else {
3641        // Horizontal motion or non-motion: sticky column tracks the
3642        // new cursor column so the *next* vertical motion aims there.
3643        ed.sticky_col = Some(ed.cursor().1);
3644    }
3645}
3646
3647fn is_vertical_motion(motion: &Motion) -> bool {
3648    // Only j / k preserve the sticky column. Everything else (search,
3649    // gg / G, word jumps, etc.) lands at the match's own column so the
3650    // sticky value should sync to the new cursor column.
3651    matches!(
3652        motion,
3653        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown
3654    )
3655}
3656
3657fn apply_motion_cursor<H: crate::types::Host>(
3658    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3659    motion: &Motion,
3660    count: usize,
3661) {
3662    apply_motion_cursor_ctx(ed, motion, count, false)
3663}
3664
3665pub(crate) fn apply_motion_cursor_ctx<H: crate::types::Host>(
3666    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3667    motion: &Motion,
3668    count: usize,
3669    as_operator: bool,
3670) {
3671    match motion {
3672        Motion::Left => {
3673            // `h` — Buffer clamps at col 0 (no wrap), matching vim.
3674            crate::motions::move_left(&mut ed.buffer, count);
3675            ed.push_buffer_cursor_to_textarea();
3676        }
3677        Motion::Right => {
3678            // `l` — operator-motion context (`dl`/`cl`/`yl`) is allowed
3679            // one past the last char so the range includes it; cursor
3680            // context clamps at the last char.
3681            if as_operator {
3682                crate::motions::move_right_to_end(&mut ed.buffer, count);
3683            } else {
3684                crate::motions::move_right_in_line(&mut ed.buffer, count);
3685            }
3686            ed.push_buffer_cursor_to_textarea();
3687        }
3688        Motion::Up => {
3689            // Final col is set by `apply_sticky_col` below — push the
3690            // post-move row to the textarea and let sticky tracking
3691            // finish the work.
3692            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3693            crate::motions::move_up(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
3694            ed.push_buffer_cursor_to_textarea();
3695        }
3696        Motion::Down => {
3697            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3698            crate::motions::move_down(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
3699            ed.push_buffer_cursor_to_textarea();
3700        }
3701        Motion::ScreenUp => {
3702            let v = *ed.host.viewport();
3703            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3704            crate::motions::move_screen_up(&mut ed.buffer, &folds, &v, count, &mut ed.sticky_col);
3705            ed.push_buffer_cursor_to_textarea();
3706        }
3707        Motion::ScreenDown => {
3708            let v = *ed.host.viewport();
3709            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3710            crate::motions::move_screen_down(&mut ed.buffer, &folds, &v, count, &mut ed.sticky_col);
3711            ed.push_buffer_cursor_to_textarea();
3712        }
3713        Motion::WordFwd => {
3714            crate::motions::move_word_fwd(&mut ed.buffer, false, count, &ed.settings.iskeyword);
3715            ed.push_buffer_cursor_to_textarea();
3716        }
3717        Motion::WordBack => {
3718            crate::motions::move_word_back(&mut ed.buffer, false, count, &ed.settings.iskeyword);
3719            ed.push_buffer_cursor_to_textarea();
3720        }
3721        Motion::WordEnd => {
3722            crate::motions::move_word_end(&mut ed.buffer, false, count, &ed.settings.iskeyword);
3723            ed.push_buffer_cursor_to_textarea();
3724        }
3725        Motion::BigWordFwd => {
3726            crate::motions::move_word_fwd(&mut ed.buffer, true, count, &ed.settings.iskeyword);
3727            ed.push_buffer_cursor_to_textarea();
3728        }
3729        Motion::BigWordBack => {
3730            crate::motions::move_word_back(&mut ed.buffer, true, count, &ed.settings.iskeyword);
3731            ed.push_buffer_cursor_to_textarea();
3732        }
3733        Motion::BigWordEnd => {
3734            crate::motions::move_word_end(&mut ed.buffer, true, count, &ed.settings.iskeyword);
3735            ed.push_buffer_cursor_to_textarea();
3736        }
3737        Motion::WordEndBack => {
3738            crate::motions::move_word_end_back(
3739                &mut ed.buffer,
3740                false,
3741                count,
3742                &ed.settings.iskeyword,
3743            );
3744            ed.push_buffer_cursor_to_textarea();
3745        }
3746        Motion::BigWordEndBack => {
3747            crate::motions::move_word_end_back(&mut ed.buffer, true, count, &ed.settings.iskeyword);
3748            ed.push_buffer_cursor_to_textarea();
3749        }
3750        Motion::LineStart => {
3751            crate::motions::move_line_start(&mut ed.buffer);
3752            ed.push_buffer_cursor_to_textarea();
3753        }
3754        Motion::FirstNonBlank => {
3755            crate::motions::move_first_non_blank(&mut ed.buffer);
3756            ed.push_buffer_cursor_to_textarea();
3757        }
3758        Motion::LineEnd => {
3759            // Vim normal-mode `$` lands on the last char, not one past it.
3760            crate::motions::move_line_end(&mut ed.buffer);
3761            ed.push_buffer_cursor_to_textarea();
3762        }
3763        Motion::FileTop => {
3764            // `count gg` jumps to line `count` (first non-blank);
3765            // bare `gg` lands at the top.
3766            if count > 1 {
3767                crate::motions::move_bottom(&mut ed.buffer, count);
3768            } else {
3769                crate::motions::move_top(&mut ed.buffer);
3770            }
3771            ed.push_buffer_cursor_to_textarea();
3772        }
3773        Motion::FileBottom => {
3774            // `count G` jumps to line `count`; bare `G` lands at
3775            // the buffer bottom (`Buffer::move_bottom(0)`).
3776            if count > 1 {
3777                crate::motions::move_bottom(&mut ed.buffer, count);
3778            } else {
3779                crate::motions::move_bottom(&mut ed.buffer, 0);
3780            }
3781            ed.push_buffer_cursor_to_textarea();
3782        }
3783        Motion::Find { ch, forward, till } => {
3784            for _ in 0..count {
3785                if !find_char_on_line(ed, *ch, *forward, *till) {
3786                    break;
3787                }
3788            }
3789        }
3790        Motion::FindRepeat { .. } => {} // already resolved upstream
3791        Motion::MatchBracket => {
3792            let _ = matching_bracket(ed);
3793        }
3794        Motion::WordAtCursor {
3795            forward,
3796            whole_word,
3797        } => {
3798            word_at_cursor_search(ed, *forward, *whole_word, count);
3799        }
3800        Motion::SearchNext { reverse } => {
3801            // Re-push the last query so the buffer's search state is
3802            // correct even if the host happened to clear it (e.g. while
3803            // a Visual mode draw was in progress).
3804            if let Some(pattern) = ed.vim.last_search.clone() {
3805                ed.push_search_pattern(&pattern);
3806            }
3807            if ed.search_state().pattern.is_none() {
3808                return;
3809            }
3810            // `n` repeats the last search in its committed direction;
3811            // `N` inverts. So a `?` search makes `n` walk backward and
3812            // `N` walk forward.
3813            let forward = ed.vim.last_search_forward != *reverse;
3814            for _ in 0..count.max(1) {
3815                if forward {
3816                    ed.search_advance_forward(true);
3817                } else {
3818                    ed.search_advance_backward(true);
3819                }
3820            }
3821            ed.push_buffer_cursor_to_textarea();
3822        }
3823        Motion::ViewportTop => {
3824            let v = *ed.host().viewport();
3825            crate::motions::move_viewport_top(&mut ed.buffer, &v, count.saturating_sub(1));
3826            ed.push_buffer_cursor_to_textarea();
3827        }
3828        Motion::ViewportMiddle => {
3829            let v = *ed.host().viewport();
3830            crate::motions::move_viewport_middle(&mut ed.buffer, &v);
3831            ed.push_buffer_cursor_to_textarea();
3832        }
3833        Motion::ViewportBottom => {
3834            let v = *ed.host().viewport();
3835            crate::motions::move_viewport_bottom(&mut ed.buffer, &v, count.saturating_sub(1));
3836            ed.push_buffer_cursor_to_textarea();
3837        }
3838        Motion::LastNonBlank => {
3839            crate::motions::move_last_non_blank(&mut ed.buffer);
3840            ed.push_buffer_cursor_to_textarea();
3841        }
3842        Motion::LineMiddle => {
3843            let row = ed.cursor().0;
3844            let line_chars = buf_line_chars(&ed.buffer, row);
3845            // Vim's `gM`: column = floor(chars / 2). Empty / single-char
3846            // lines stay at col 0.
3847            let target = line_chars / 2;
3848            ed.jump_cursor(row, target);
3849        }
3850        Motion::ParagraphPrev => {
3851            crate::motions::move_paragraph_prev(&mut ed.buffer, count);
3852            ed.push_buffer_cursor_to_textarea();
3853        }
3854        Motion::ParagraphNext => {
3855            crate::motions::move_paragraph_next(&mut ed.buffer, count);
3856            ed.push_buffer_cursor_to_textarea();
3857        }
3858        Motion::SentencePrev => {
3859            for _ in 0..count.max(1) {
3860                if let Some((row, col)) = sentence_boundary(ed, false) {
3861                    ed.jump_cursor(row, col);
3862                }
3863            }
3864        }
3865        Motion::SentenceNext => {
3866            for _ in 0..count.max(1) {
3867                if let Some((row, col)) = sentence_boundary(ed, true) {
3868                    ed.jump_cursor(row, col);
3869                }
3870            }
3871        }
3872        Motion::SectionBackward => {
3873            crate::motions::move_section_backward(&mut ed.buffer, count);
3874            ed.push_buffer_cursor_to_textarea();
3875        }
3876        Motion::SectionForward => {
3877            crate::motions::move_section_forward(&mut ed.buffer, count);
3878            ed.push_buffer_cursor_to_textarea();
3879        }
3880        Motion::SectionEndBackward => {
3881            crate::motions::move_section_end_backward(&mut ed.buffer, count);
3882            ed.push_buffer_cursor_to_textarea();
3883        }
3884        Motion::SectionEndForward => {
3885            crate::motions::move_section_end_forward(&mut ed.buffer, count);
3886            ed.push_buffer_cursor_to_textarea();
3887        }
3888        Motion::FirstNonBlankNextLine => {
3889            crate::motions::move_first_non_blank_next_line(&mut ed.buffer, count);
3890            ed.push_buffer_cursor_to_textarea();
3891        }
3892        Motion::FirstNonBlankPrevLine => {
3893            crate::motions::move_first_non_blank_prev_line(&mut ed.buffer, count);
3894            ed.push_buffer_cursor_to_textarea();
3895        }
3896        Motion::FirstNonBlankLine => {
3897            crate::motions::move_first_non_blank_line(&mut ed.buffer, count);
3898            ed.push_buffer_cursor_to_textarea();
3899        }
3900        Motion::GotoColumn => {
3901            crate::motions::move_goto_column(&mut ed.buffer, count);
3902            ed.push_buffer_cursor_to_textarea();
3903        }
3904    }
3905}
3906
3907fn move_first_non_whitespace<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
3908    // Some call sites invoke this right after `dd` / `<<` / `>>` etc
3909    // mutates the textarea content, so the migration buffer hasn't
3910    // seen the new lines OR new cursor yet. Mirror the full content
3911    // across before delegating, then push the result back so the
3912    // textarea reflects the resolved column too.
3913    ed.sync_buffer_content_from_textarea();
3914    crate::motions::move_first_non_blank(&mut ed.buffer);
3915    ed.push_buffer_cursor_to_textarea();
3916}
3917
3918fn find_char_on_line<H: crate::types::Host>(
3919    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3920    ch: char,
3921    forward: bool,
3922    till: bool,
3923) -> bool {
3924    let moved = crate::motions::find_char_on_line(&mut ed.buffer, ch, forward, till);
3925    if moved {
3926        ed.push_buffer_cursor_to_textarea();
3927    }
3928    moved
3929}
3930
3931fn matching_bracket<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) -> bool {
3932    let moved = crate::motions::match_bracket(&mut ed.buffer);
3933    if moved {
3934        ed.push_buffer_cursor_to_textarea();
3935    }
3936    moved
3937}
3938
3939fn word_at_cursor_search<H: crate::types::Host>(
3940    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3941    forward: bool,
3942    whole_word: bool,
3943    count: usize,
3944) {
3945    let (row, col) = ed.cursor();
3946    let line: String = buf_line(&ed.buffer, row).unwrap_or_default();
3947    let chars: Vec<char> = line.chars().collect();
3948    if chars.is_empty() {
3949        return;
3950    }
3951    // Expand around cursor to a word boundary.
3952    let spec = ed.settings().iskeyword.clone();
3953    let is_word = |c: char| is_keyword_char(c, &spec);
3954    let mut start = col.min(chars.len().saturating_sub(1));
3955    while start > 0 && is_word(chars[start - 1]) {
3956        start -= 1;
3957    }
3958    let mut end = start;
3959    while end < chars.len() && is_word(chars[end]) {
3960        end += 1;
3961    }
3962    if end <= start {
3963        return;
3964    }
3965    let word: String = chars[start..end].iter().collect();
3966    let escaped = regex_escape(&word);
3967    let pattern = if whole_word {
3968        format!(r"\b{escaped}\b")
3969    } else {
3970        escaped
3971    };
3972    ed.push_search_pattern(&pattern);
3973    if ed.search_state().pattern.is_none() {
3974        return;
3975    }
3976    // Remember the query so `n` / `N` keep working after the jump.
3977    ed.vim.last_search = Some(pattern);
3978    ed.vim.last_search_forward = forward;
3979    for _ in 0..count.max(1) {
3980        if forward {
3981            ed.search_advance_forward(true);
3982        } else {
3983            ed.search_advance_backward(true);
3984        }
3985    }
3986    ed.push_buffer_cursor_to_textarea();
3987}
3988
3989fn regex_escape(s: &str) -> String {
3990    let mut out = String::with_capacity(s.len());
3991    for c in s.chars() {
3992        if matches!(
3993            c,
3994            '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\'
3995        ) {
3996            out.push('\\');
3997        }
3998        out.push(c);
3999    }
4000    out
4001}
4002
4003// ─── Operator application ──────────────────────────────────────────────────
4004
4005/// Public(crate) entry: apply operator over the motion identified by a raw
4006/// char key. Called by `Editor::apply_op_motion` (the public controller API)
4007/// so the hjkl-vim pending-state reducer can dispatch `ApplyOpMotion` without
4008/// re-entering the FSM.
4009///
4010/// Applies standard vim quirks:
4011/// - `cw` / `cW` → `ce` / `cE`
4012/// - `FindRepeat` → resolves against `last_find`
4013/// - Updates `last_find` and `last_change` per existing conventions.
4014///
4015/// No-op when `motion_key` does not produce a known motion.
4016pub(crate) fn apply_op_motion_key<H: crate::types::Host>(
4017    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4018    op: Operator,
4019    motion_key: char,
4020    total_count: usize,
4021) {
4022    let input = Input {
4023        key: Key::Char(motion_key),
4024        ctrl: false,
4025        alt: false,
4026        shift: false,
4027    };
4028    let Some(motion) = parse_motion(&input) else {
4029        return;
4030    };
4031    let motion = match motion {
4032        Motion::FindRepeat { reverse } => match ed.vim.last_find {
4033            Some((ch, forward, till)) => Motion::Find {
4034                ch,
4035                forward: if reverse { !forward } else { forward },
4036                till,
4037            },
4038            None => return,
4039        },
4040        // Vim quirk: `cw` / `cW` → `ce` / `cE`.
4041        Motion::WordFwd if op == Operator::Change => Motion::WordEnd,
4042        Motion::BigWordFwd if op == Operator::Change => Motion::BigWordEnd,
4043        m => m,
4044    };
4045    apply_op_with_motion(ed, op, &motion, total_count);
4046    if let Motion::Find { ch, forward, till } = &motion {
4047        ed.vim.last_find = Some((*ch, *forward, *till));
4048    }
4049    if !ed.vim.replaying && op_is_change(op) {
4050        ed.vim.last_change = Some(LastChange::OpMotion {
4051            op,
4052            motion,
4053            count: total_count,
4054            inserted: None,
4055        });
4056    }
4057}
4058
4059/// Public(crate) entry: apply doubled-letter line op (`dd`/`yy`/`cc`/`>>`/`<<`/`gcc`).
4060/// Called by `Editor::apply_op_double` (the public controller API).
4061pub(crate) fn apply_op_double<H: crate::types::Host>(
4062    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4063    op: Operator,
4064    total_count: usize,
4065) {
4066    if op == Operator::Comment {
4067        // `gcc` / `{N}gcc` — toggle comment on `total_count` lines starting at cursor.
4068        let row = buf_cursor_pos(&ed.buffer).row;
4069        let end_row = (row + total_count.max(1) - 1).min(ed.buffer.row_count().saturating_sub(1));
4070        ed.toggle_comment_range(row, end_row);
4071        ed.vim.mode = Mode::Normal;
4072        if !ed.vim.replaying {
4073            ed.vim.last_change = Some(LastChange::LineOp {
4074                op,
4075                count: total_count,
4076                inserted: None,
4077            });
4078        }
4079        return;
4080    }
4081    execute_line_op(ed, op, total_count);
4082    if !ed.vim.replaying {
4083        ed.vim.last_change = Some(LastChange::LineOp {
4084            op,
4085            count: total_count,
4086            inserted: None,
4087        });
4088    }
4089}
4090
4091/// Shared implementation: apply operator over a g-chord motion or case-op
4092/// linewise form. Called by `Editor::apply_op_g` (the public controller API)
4093/// so the hjkl-vim reducer can dispatch `ApplyOpG` without re-entering the FSM.
4094///
4095/// - If `op` is Uppercase/Lowercase/ToggleCase and `ch` matches the op's char
4096///   (`U`/`u`/`~`): executes the line op and updates `last_change`.
4097/// - Otherwise, maps `ch` to a motion (`g`→FileTop, `e`→WordEndBack,
4098///   `E`→BigWordEndBack, `j`→ScreenDown, `k`→ScreenUp) and applies. Unknown
4099///   chars are silently ignored (no-op), matching the engine FSM's behaviour.
4100pub(crate) fn apply_op_g_inner<H: crate::types::Host>(
4101    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4102    op: Operator,
4103    ch: char,
4104    total_count: usize,
4105) {
4106    // Case-op linewise form: `gUgU`, `gugu`, `g~g~` — same effect as
4107    // `gUU` / `guu` / `g~~`.
4108    if matches!(
4109        op,
4110        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase
4111    ) {
4112        let op_char = match op {
4113            Operator::Uppercase => 'U',
4114            Operator::Lowercase => 'u',
4115            Operator::ToggleCase => '~',
4116            _ => unreachable!(),
4117        };
4118        if ch == op_char {
4119            execute_line_op(ed, op, total_count);
4120            if !ed.vim.replaying {
4121                ed.vim.last_change = Some(LastChange::LineOp {
4122                    op,
4123                    count: total_count,
4124                    inserted: None,
4125                });
4126            }
4127            return;
4128        }
4129    }
4130    let motion = match ch {
4131        'g' => Motion::FileTop,
4132        'e' => Motion::WordEndBack,
4133        'E' => Motion::BigWordEndBack,
4134        'j' => Motion::ScreenDown,
4135        'k' => Motion::ScreenUp,
4136        _ => return, // Unknown char — no-op.
4137    };
4138    apply_op_with_motion(ed, op, &motion, total_count);
4139    if !ed.vim.replaying && op_is_change(op) {
4140        ed.vim.last_change = Some(LastChange::OpMotion {
4141            op,
4142            motion,
4143            count: total_count,
4144            inserted: None,
4145        });
4146    }
4147}
4148
4149/// Public(crate) entry point for bare `g<x>`. Applies the g-chord effect
4150/// given the char `ch` and pre-captured `count`. Called by `Editor::after_g`
4151/// (the public controller API) so the hjkl-vim pending-state reducer can
4152/// dispatch `AfterGChord` without re-entering the FSM.
4153pub(crate) fn apply_after_g<H: crate::types::Host>(
4154    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4155    ch: char,
4156    count: usize,
4157) {
4158    match ch {
4159        'g' => {
4160            // gg — top / jump to line count.
4161            let pre = ed.cursor();
4162            if count > 1 {
4163                ed.jump_cursor(count - 1, 0);
4164            } else {
4165                ed.jump_cursor(0, 0);
4166            }
4167            move_first_non_whitespace(ed);
4168            // Update sticky_col to the first-non-blank column so j/k after
4169            // gg aim for the correct column per vim semantics.
4170            ed.sticky_col = Some(ed.cursor().1);
4171            if ed.cursor() != pre {
4172                ed.push_jump(pre);
4173            }
4174        }
4175        'e' => execute_motion(ed, Motion::WordEndBack, count),
4176        'E' => execute_motion(ed, Motion::BigWordEndBack, count),
4177        // `g_` — last non-blank on the line.
4178        '_' => execute_motion(ed, Motion::LastNonBlank, count),
4179        // `gM` — middle char column of the current line.
4180        'M' => execute_motion(ed, Motion::LineMiddle, count),
4181        // `gv` — re-enter the last visual selection.
4182        // Phase 6.6a: drive through the public Editor API.
4183        'v' => ed.reenter_last_visual(),
4184        // `gj` / `gk` — display-line down / up. Walks one screen
4185        // segment at a time under `:set wrap`; falls back to `j`/`k`
4186        // when wrap is off (Buffer::move_screen_* handles the branch).
4187        'j' => execute_motion(ed, Motion::ScreenDown, count),
4188        'k' => execute_motion(ed, Motion::ScreenUp, count),
4189        // Case operators: `gU` / `gu` / `g~`. Enter operator-pending
4190        // so the next input is treated as the motion / text object /
4191        // shorthand double (`gUU`, `guu`, `g~~`).
4192        'U' => {
4193            ed.vim.pending = Pending::Op {
4194                op: Operator::Uppercase,
4195                count1: count,
4196            };
4197        }
4198        'u' => {
4199            ed.vim.pending = Pending::Op {
4200                op: Operator::Lowercase,
4201                count1: count,
4202            };
4203        }
4204        '~' => {
4205            ed.vim.pending = Pending::Op {
4206                op: Operator::ToggleCase,
4207                count1: count,
4208            };
4209        }
4210        'q' => {
4211            // `gq{motion}` — text reflow operator. Subsequent motion
4212            // / textobj rides the same operator pipeline.
4213            ed.vim.pending = Pending::Op {
4214                op: Operator::Reflow,
4215                count1: count,
4216            };
4217        }
4218        'w' => {
4219            // `gw{motion}` — same reflow as `gq` but cursor stays at
4220            // its pre-reflow position (clamped to new EOL if shorter).
4221            ed.vim.pending = Pending::Op {
4222                op: Operator::ReflowKeepCursor,
4223                count1: count,
4224            };
4225        }
4226        'J' => {
4227            // `gJ` — join line below without inserting a space.
4228            for _ in 0..count.max(1) {
4229                ed.push_undo();
4230                join_line_raw(ed);
4231            }
4232            if !ed.vim.replaying {
4233                ed.vim.last_change = Some(LastChange::JoinLine {
4234                    count: count.max(1),
4235                });
4236            }
4237        }
4238        'd' => {
4239            // `gd` — goto definition. hjkl-engine doesn't run an LSP
4240            // itself; raise an intent the host drains and routes to
4241            // `sqls`. The cursor stays put here — the host moves it
4242            // once it has the target location.
4243            ed.pending_lsp = Some(crate::editor::LspIntent::GotoDefinition);
4244        }
4245        // `gi` — go to last-insert position and re-enter insert mode.
4246        // Matches vim's `:h gi`: moves to the `'^` mark position (the
4247        // cursor where insert mode was last active, before Esc step-back)
4248        // and enters insert mode there.
4249        'i' => {
4250            if let Some((row, col)) = ed.vim.last_insert_pos {
4251                ed.jump_cursor(row, col);
4252            }
4253            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::I));
4254        }
4255        // `gc` — enter operator-pending for the comment-toggle operator.
4256        // `gcc` (doubled 'c') is the line-wise form; `gc{motion}` is the
4257        // motion form. The operator is Comment — the app layer (or the
4258        // doubled-char path in handle_after_op) calls toggle_comment_range.
4259        'c' => {
4260            ed.vim.pending = Pending::Op {
4261                op: Operator::Comment,
4262                count1: count,
4263            };
4264        }
4265        // `g;` / `g,` — walk the change list. `g;` toward older
4266        // entries, `g,` toward newer.
4267        ';' => walk_change_list(ed, -1, count.max(1)),
4268        ',' => walk_change_list(ed, 1, count.max(1)),
4269        // `g*` / `g#` — like `*` / `#` but match substrings (no `\b`
4270        // boundary anchors), so the cursor on `foo` finds it inside
4271        // `foobar` too.
4272        '*' => execute_motion(
4273            ed,
4274            Motion::WordAtCursor {
4275                forward: true,
4276                whole_word: false,
4277            },
4278            count,
4279        ),
4280        '#' => execute_motion(
4281            ed,
4282            Motion::WordAtCursor {
4283                forward: false,
4284                whole_word: false,
4285            },
4286            count,
4287        ),
4288        // `g&` — repeat last `:s` over the whole buffer (1,$), keeping all
4289        // original flags. Equivalent to `:%s//~/&` in vim.
4290        '&' => {
4291            let cmd = match ed.vim.last_substitute.clone() {
4292                Some(c) => c,
4293                None => {
4294                    // No prior substitute — mirror the `:&` error path; do
4295                    // nothing to the buffer (the host's status line will show
4296                    // the pending error if wired; for headless / test hosts
4297                    // we simply return silently).
4298                    return;
4299                }
4300            };
4301            let last_row = buf_row_count(&ed.buffer).saturating_sub(1) as u32;
4302            let r = 0u32..=last_row;
4303            // apply_substitute moves cursor to last changed line and pushes
4304            // one undo snapshot — same semantics as `:&&` / `:%s//~/&`.
4305            let _ = crate::substitute::apply_substitute(ed, &cmd, r);
4306            // Update stored substitute so subsequent `g&` sees the same cmd.
4307            // (apply_substitute doesn't call set_last_substitute itself.)
4308            ed.vim.last_substitute = Some(cmd);
4309        }
4310        _ => {}
4311    }
4312}
4313
4314/// Public(crate) entry point for bare `z<x>`. Applies the z-chord effect
4315/// given the char `ch` and pre-captured `count`. Called by `Editor::after_z`
4316/// (the public controller API) so the hjkl-vim pending-state reducer can
4317/// dispatch `AfterZChord` without re-entering the engine FSM.
4318pub(crate) fn apply_after_z<H: crate::types::Host>(
4319    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4320    ch: char,
4321    count: usize,
4322) {
4323    use crate::editor::CursorScrollTarget;
4324    let row = ed.cursor().0;
4325    match ch {
4326        'z' => {
4327            ed.scroll_cursor_to(CursorScrollTarget::Center);
4328            ed.vim.viewport_pinned = true;
4329        }
4330        't' => {
4331            ed.scroll_cursor_to(CursorScrollTarget::Top);
4332            ed.vim.viewport_pinned = true;
4333        }
4334        'b' => {
4335            ed.scroll_cursor_to(CursorScrollTarget::Bottom);
4336            ed.vim.viewport_pinned = true;
4337        }
4338        // Folds — operate on the fold under the cursor (or the
4339        // whole buffer for `R` / `M`). Routed through
4340        // [`Editor::apply_fold_op`] (0.0.38 Patch C-δ.4) so the host
4341        // can observe / veto each op via [`Editor::take_fold_ops`].
4342        'o' => {
4343            ed.apply_fold_op(crate::types::FoldOp::OpenAt(row));
4344        }
4345        'c' => {
4346            ed.apply_fold_op(crate::types::FoldOp::CloseAt(row));
4347        }
4348        'a' => {
4349            ed.apply_fold_op(crate::types::FoldOp::ToggleAt(row));
4350        }
4351        'R' => {
4352            ed.apply_fold_op(crate::types::FoldOp::OpenAll);
4353        }
4354        'M' => {
4355            ed.apply_fold_op(crate::types::FoldOp::CloseAll);
4356        }
4357        'E' => {
4358            ed.apply_fold_op(crate::types::FoldOp::ClearAll);
4359        }
4360        'd' => {
4361            ed.apply_fold_op(crate::types::FoldOp::RemoveAt(row));
4362        }
4363        'f' => {
4364            if matches!(
4365                ed.vim.mode,
4366                Mode::Visual | Mode::VisualLine | Mode::VisualBlock
4367            ) {
4368                // `zf` over a Visual selection creates a fold spanning
4369                // anchor → cursor.
4370                let anchor_row = match ed.vim.mode {
4371                    Mode::VisualLine => ed.vim.visual_line_anchor,
4372                    Mode::VisualBlock => ed.vim.block_anchor.0,
4373                    _ => ed.vim.visual_anchor.0,
4374                };
4375                let cur = ed.cursor().0;
4376                let top = anchor_row.min(cur);
4377                let bot = anchor_row.max(cur);
4378                ed.apply_fold_op(crate::types::FoldOp::Add {
4379                    start_row: top,
4380                    end_row: bot,
4381                    closed: true,
4382                });
4383                ed.vim.mode = Mode::Normal;
4384            } else {
4385                // `zf{motion}` / `zf{textobj}` — route through the
4386                // operator pipeline. `Operator::Fold` reuses every
4387                // motion / text-object / `g`-prefix branch the other
4388                // operators get.
4389                ed.vim.pending = Pending::Op {
4390                    op: Operator::Fold,
4391                    count1: count,
4392                };
4393            }
4394        }
4395        _ => {}
4396    }
4397}
4398
4399/// Public(crate) entry point for bare `f<x>` / `F<x>` / `t<x>` / `T<x>`.
4400/// Applies the motion and records `last_find` for `;` / `,` repeat.
4401/// Called by `Editor::find_char` (the public controller API) so the
4402/// hjkl-vim pending-state reducer can dispatch `FindChar` without
4403/// re-entering the FSM.
4404pub(crate) fn apply_find_char<H: crate::types::Host>(
4405    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4406    ch: char,
4407    forward: bool,
4408    till: bool,
4409    count: usize,
4410) {
4411    execute_motion(ed, Motion::Find { ch, forward, till }, count.max(1));
4412    ed.vim.last_find = Some((ch, forward, till));
4413    ed.vim.last_horizontal_motion = LastHorizontalMotion::FindChar;
4414}
4415
4416// ─── Sneak motion ──────────────────────────────────────────────────────────
4417
4418/// Scan the buffer from the current cursor position for the `count`-th
4419/// occurrence of the two-char digraph `(c1, c2)`.
4420///
4421/// - `forward=true` → scan downward (rows) and rightward (cols) past cursor.
4422/// - `forward=false` → scan upward and leftward.
4423///
4424/// When a match is found the cursor jumps to the first char of the digraph.
4425/// `last_sneak` and `last_horizontal_motion` are updated so `;`/`,` repeat.
4426/// No-op (cursor unchanged) when no match exists.
4427pub(crate) fn apply_sneak<H: crate::types::Host>(
4428    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4429    c1: char,
4430    c2: char,
4431    forward: bool,
4432    count: usize,
4433) {
4434    let count = count.max(1);
4435    let (start_row, start_col) = ed.cursor();
4436    let row_count = buf_row_count(&ed.buffer);
4437
4438    let result = if forward {
4439        sneak_scan_forward(ed, start_row, start_col, c1, c2, count)
4440    } else {
4441        sneak_scan_backward(ed, start_row, start_col, c1, c2, count)
4442    };
4443
4444    if let Some((row, col)) = result {
4445        buf_set_cursor_rc(&mut ed.buffer, row, col);
4446        ed.push_buffer_cursor_to_textarea();
4447        let _ = row_count; // suppress unused-variable warning
4448    }
4449
4450    ed.vim.last_sneak = Some(((c1, c2), forward));
4451    ed.vim.last_horizontal_motion = LastHorizontalMotion::Sneak;
4452}
4453
4454/// Scan forward from `(start_row, start_col)` (exclusive — start right after
4455/// cursor) for the `count`-th occurrence of `c1+c2`.
4456fn sneak_scan_forward<H: crate::types::Host>(
4457    ed: &Editor<hjkl_buffer::Buffer, H>,
4458    start_row: usize,
4459    start_col: usize,
4460    c1: char,
4461    c2: char,
4462    count: usize,
4463) -> Option<(usize, usize)> {
4464    let row_count = buf_row_count(&ed.buffer);
4465    let mut hits = 0usize;
4466    for row in start_row..row_count {
4467        let line = buf_line(&ed.buffer, row).unwrap_or_default();
4468        let chars: Vec<char> = line.chars().collect();
4469        // On the start row begin scanning one past the current column.
4470        let col_start = if row == start_row { start_col + 1 } else { 0 };
4471        if col_start + 1 > chars.len() {
4472            continue;
4473        }
4474        for col in col_start..chars.len().saturating_sub(1) {
4475            if chars[col] == c1 && chars[col + 1] == c2 {
4476                hits += 1;
4477                if hits == count {
4478                    return Some((row, col));
4479                }
4480            }
4481        }
4482    }
4483    None
4484}
4485
4486/// Scan backward from `(start_row, start_col)` (exclusive — start left of
4487/// cursor) for the `count`-th occurrence of `c1+c2`.
4488fn sneak_scan_backward<H: crate::types::Host>(
4489    ed: &Editor<hjkl_buffer::Buffer, H>,
4490    start_row: usize,
4491    start_col: usize,
4492    c1: char,
4493    c2: char,
4494    count: usize,
4495) -> Option<(usize, usize)> {
4496    let row_count = buf_row_count(&ed.buffer);
4497    let mut hits = 0usize;
4498    // Iterate rows from start_row down to 0.
4499    let rows_to_scan = (0..row_count).rev().skip(row_count - start_row - 1);
4500    for row in rows_to_scan {
4501        let line = buf_line(&ed.buffer, row).unwrap_or_default();
4502        let chars: Vec<char> = line.chars().collect();
4503        // On the start row end scanning one before the current column.
4504        let col_end = if row == start_row {
4505            start_col.saturating_sub(1)
4506        } else if chars.is_empty() {
4507            continue;
4508        } else {
4509            chars.len().saturating_sub(1)
4510        };
4511        if col_end == 0 {
4512            continue;
4513        }
4514        // Scan cols right-to-left from col_end-1 so we match c1 at col, c2 at col+1.
4515        for col in (0..col_end).rev() {
4516            if col + 1 < chars.len() && chars[col] == c1 && chars[col + 1] == c2 {
4517                hits += 1;
4518                if hits == count {
4519                    return Some((row, col));
4520                }
4521            }
4522        }
4523    }
4524    None
4525}
4526
4527/// Apply `op` over the sneak digraph range. Charwise exclusive from cursor up
4528/// to (but not including) the first char of the first match. This matches
4529/// vim-sneak's default `<Plug>Sneak_s` operator-pending behavior.
4530///
4531/// Example: buffer `"foo ab bar\n"`, cursor col 0, `dsab` → deletes `"foo "`
4532/// leaving `"ab bar\n"`.
4533pub(crate) fn apply_op_sneak<H: crate::types::Host>(
4534    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4535    op: Operator,
4536    c1: char,
4537    c2: char,
4538    forward: bool,
4539    total_count: usize,
4540) {
4541    let start = ed.cursor();
4542    let result = if forward {
4543        sneak_scan_forward(ed, start.0, start.1, c1, c2, total_count)
4544    } else {
4545        sneak_scan_backward(ed, start.0, start.1, c1, c2, total_count)
4546    };
4547    let Some(end) = result else {
4548        return;
4549    };
4550    // Charwise exclusive — land the virtual cursor at end, then use
4551    // Exclusive range kind (end position not included).
4552    ed.jump_cursor(end.0, end.1);
4553    let end_cur = ed.cursor();
4554    ed.jump_cursor(start.0, start.1);
4555    run_operator_over_range(ed, op, start, end_cur, RangeKind::Exclusive);
4556    ed.vim.last_sneak = Some(((c1, c2), forward));
4557    ed.vim.last_horizontal_motion = LastHorizontalMotion::Sneak;
4558    if !ed.vim.replaying && op_is_change(op) {
4559        // No dot-repeat motion variant for sneak ops (plugin behavior,
4560        // not vim-core); record as a Change/Delete line op as a
4561        // best-effort fallback so `.` at least does something.
4562    }
4563}
4564
4565/// Public(crate) entry: apply operator over a find motion (`df<x>` etc.).
4566/// Called by `Editor::apply_op_find` (the public controller API) so the
4567/// hjkl-vim `PendingState::OpFind` reducer can dispatch `ApplyOpFind` without
4568/// re-entering the FSM. `handle_op_find_target` now delegates here to avoid
4569/// logic duplication.
4570pub(crate) fn apply_op_find_motion<H: crate::types::Host>(
4571    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4572    op: Operator,
4573    ch: char,
4574    forward: bool,
4575    till: bool,
4576    total_count: usize,
4577) {
4578    let motion = Motion::Find { ch, forward, till };
4579    apply_op_with_motion(ed, op, &motion, total_count);
4580    ed.vim.last_find = Some((ch, forward, till));
4581    if !ed.vim.replaying && op_is_change(op) {
4582        ed.vim.last_change = Some(LastChange::OpMotion {
4583            op,
4584            motion,
4585            count: total_count,
4586            inserted: None,
4587        });
4588    }
4589}
4590
4591/// Shared implementation: map `ch` to `TextObject`, apply the operator, and
4592/// record `last_change`. Returns `false` when `ch` is not a known text-object
4593/// kind (caller should treat as a no-op). Called by `Editor::apply_op_text_obj`
4594/// (the public controller API) so hjkl-vim can dispatch without re-entering the FSM.
4595///
4596/// `_total_count` is accepted for API symmetry with `apply_op_find_motion` /
4597/// `apply_op_motion_key` but is currently unused — text objects don't repeat
4598/// in vim's current grammar. Kept for future-proofing.
4599pub(crate) fn apply_op_text_obj_inner<H: crate::types::Host>(
4600    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4601    op: Operator,
4602    ch: char,
4603    inner: bool,
4604    total_count: usize,
4605) -> bool {
4606    // `total_count` drives bracket text objects: `2di{` targets the Nth
4607    // enclosing pair. Non-bracket objects ignore it (vim does too).
4608    let obj = match ch {
4609        'w' => TextObject::Word { big: false },
4610        'W' => TextObject::Word { big: true },
4611        '"' | '\'' | '`' => TextObject::Quote(ch),
4612        '(' | ')' | 'b' => TextObject::Bracket('('),
4613        '[' | ']' => TextObject::Bracket('['),
4614        '{' | '}' | 'B' => TextObject::Bracket('{'),
4615        '<' | '>' => TextObject::Bracket('<'),
4616        'p' => TextObject::Paragraph,
4617        't' => TextObject::XmlTag,
4618        's' => TextObject::Sentence,
4619        _ => return false,
4620    };
4621    apply_op_with_text_object(ed, op, obj, inner, total_count.max(1));
4622    if !ed.vim.replaying && op_is_change(op) {
4623        ed.vim.last_change = Some(LastChange::OpTextObj {
4624            op,
4625            obj,
4626            inner,
4627            inserted: None,
4628        });
4629    }
4630    true
4631}
4632
4633/// Move `pos` back by one character, clamped to (0, 0).
4634pub(crate) fn retreat_one<H: crate::types::Host>(
4635    ed: &Editor<hjkl_buffer::Buffer, H>,
4636    pos: (usize, usize),
4637) -> (usize, usize) {
4638    let (r, c) = pos;
4639    if c > 0 {
4640        (r, c - 1)
4641    } else if r > 0 {
4642        let prev_len = buf_line_bytes(&ed.buffer, r - 1);
4643        (r - 1, prev_len)
4644    } else {
4645        (0, 0)
4646    }
4647}
4648
4649/// Variant of begin_insert that doesn't push_undo (caller already did).
4650fn begin_insert_noundo<H: crate::types::Host>(
4651    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4652    count: usize,
4653    reason: InsertReason,
4654) {
4655    let reason = if ed.vim.replaying {
4656        InsertReason::ReplayOnly
4657    } else {
4658        reason
4659    };
4660    let (row, _) = ed.cursor();
4661    ed.vim.insert_session = Some(InsertSession {
4662        count,
4663        row_min: row,
4664        row_max: row,
4665        before_rope: crate::types::Query::rope(&ed.buffer),
4666        reason,
4667    });
4668    ed.vim.mode = Mode::Insert;
4669    // Phase 6.3: keep current_mode in sync for callers that bypass step().
4670    ed.vim.current_mode = crate::VimMode::Insert;
4671}
4672
4673// ─── Operator × Motion application ─────────────────────────────────────────
4674
4675pub(crate) fn apply_op_with_motion<H: crate::types::Host>(
4676    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4677    op: Operator,
4678    motion: &Motion,
4679    count: usize,
4680) {
4681    let start = ed.cursor();
4682    // Tentatively apply motion to find the endpoint. Operator context
4683    // so `l` on the last char advances past-last (standard vim
4684    // exclusive-motion endpoint behaviour), enabling `dl` / `cl` /
4685    // `yl` to cover the final char.
4686    apply_motion_cursor_ctx(ed, motion, count, true);
4687    let end = ed.cursor();
4688    let kind = motion_kind(motion);
4689    // Restore cursor before selecting (so Yank leaves cursor at start).
4690    ed.jump_cursor(start.0, start.1);
4691
4692    // Comment is always linewise regardless of motion kind — toggle rows.
4693    if op == Operator::Comment {
4694        let top = start.0.min(end.0);
4695        let bot = start.0.max(end.0);
4696        ed.toggle_comment_range(top, bot);
4697        ed.vim.mode = Mode::Normal;
4698        return;
4699    }
4700
4701    run_operator_over_range(ed, op, start, end, kind);
4702}
4703
4704fn apply_op_with_text_object<H: crate::types::Host>(
4705    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4706    op: Operator,
4707    obj: TextObject,
4708    inner: bool,
4709    count: usize,
4710) {
4711    let Some((start, end, kind)) = text_object_range(ed, obj, inner, count) else {
4712        return;
4713    };
4714    ed.jump_cursor(start.0, start.1);
4715    run_operator_over_range(ed, op, start, end, kind);
4716}
4717
4718fn motion_kind(motion: &Motion) -> RangeKind {
4719    match motion {
4720        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown => RangeKind::Linewise,
4721        Motion::FileTop | Motion::FileBottom => RangeKind::Linewise,
4722        Motion::ViewportTop | Motion::ViewportMiddle | Motion::ViewportBottom => {
4723            RangeKind::Linewise
4724        }
4725        Motion::WordEnd | Motion::BigWordEnd | Motion::WordEndBack | Motion::BigWordEndBack => {
4726            RangeKind::Inclusive
4727        }
4728        Motion::Find { .. } => RangeKind::Inclusive,
4729        Motion::MatchBracket => RangeKind::Inclusive,
4730        // `$` now lands on the last char — operator ranges include it.
4731        Motion::LineEnd => RangeKind::Inclusive,
4732        // Linewise motions: +/-/_ land on the first non-blank of a line.
4733        Motion::FirstNonBlankNextLine
4734        | Motion::FirstNonBlankPrevLine
4735        | Motion::FirstNonBlankLine => RangeKind::Linewise,
4736        // [[/]]/[][/][ are charwise exclusive (land on the brace, brace excluded from operator).
4737        Motion::SectionBackward
4738        | Motion::SectionForward
4739        | Motion::SectionEndBackward
4740        | Motion::SectionEndForward => RangeKind::Exclusive,
4741        _ => RangeKind::Exclusive,
4742    }
4743}
4744
4745/// Linewise change of rows `[top_row, end_row]` (vim `cc`/`cj`/`Vc`/`cip`…).
4746///
4747/// Deletes the spanned lines, leaves one line carrying the first row's
4748/// leading whitespace (when `autoindent` is on), parks the cursor after
4749/// the indent, and enters insert mode. Records the full linewise payload
4750/// to the yank + delete registers and sets `change_mark_start` for the
4751/// `[`/`]` deferral. Calls `push_undo` internally — callers must NOT also
4752/// call it.
4753fn change_linewise_rows<H: crate::types::Host>(
4754    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4755    top_row: usize,
4756    end_row: usize,
4757) {
4758    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
4759    // Vim `:h '[`: stash change start for `]` deferral on insert-exit.
4760    ed.vim.change_mark_start = Some((top_row, 0));
4761    ed.push_undo();
4762    ed.sync_buffer_content_from_textarea();
4763    // Read the cut payload first so yank reflects every original line.
4764    let payload = read_vim_range(ed, (top_row, 0), (end_row, 0), RangeKind::Linewise);
4765    // Drop every row after the first (rows [top_row+1, end_row]).
4766    if end_row > top_row {
4767        ed.mutate_edit(Edit::DeleteRange {
4768            start: Position::new(top_row + 1, 0),
4769            end: Position::new(end_row, 0),
4770            kind: BufKind::Line,
4771        });
4772    }
4773    // Preserve the first row's leading whitespace when autoindent is on;
4774    // wipe the whole line content otherwise (cursor lands at col 0).
4775    let indent_chars = if ed.settings.autoindent {
4776        let line = hjkl_buffer::rope_line_str(&crate::types::Query::rope(&ed.buffer), top_row);
4777        line.chars().take_while(|c| *c == ' ' || *c == '\t').count()
4778    } else {
4779        0
4780    };
4781    let line_chars = buf_line_chars(&ed.buffer, top_row);
4782    if line_chars > indent_chars {
4783        ed.mutate_edit(Edit::DeleteRange {
4784            start: Position::new(top_row, indent_chars),
4785            end: Position::new(top_row, line_chars),
4786            kind: BufKind::Char,
4787        });
4788    }
4789    if !payload.is_empty() {
4790        ed.record_yank_to_host(payload.clone());
4791        ed.record_delete(payload, true);
4792    }
4793    buf_set_cursor_rc(&mut ed.buffer, top_row, indent_chars);
4794    ed.push_buffer_cursor_to_textarea();
4795    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
4796}
4797
4798fn run_operator_over_range<H: crate::types::Host>(
4799    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4800    op: Operator,
4801    start: (usize, usize),
4802    end: (usize, usize),
4803    kind: RangeKind,
4804) {
4805    let (top, bot) = order(start, end);
4806    // Charwise empty range (same position) — nothing to act on. For Linewise
4807    // the range `top == bot` means "operate on this one line" which is
4808    // perfectly valid (e.g. `Vd` on a single-line VisualLine selection).
4809    if top == bot && !matches!(kind, RangeKind::Linewise) {
4810        return;
4811    }
4812
4813    match op {
4814        Operator::Yank => {
4815            let text = read_vim_range(ed, top, bot, kind);
4816            if !text.is_empty() {
4817                ed.record_yank_to_host(text.clone());
4818                ed.record_yank(text, matches!(kind, RangeKind::Linewise));
4819            }
4820            // Vim `:h '[` / `:h ']`: after a yank `[` = first yanked char,
4821            // `]` = last yanked char. Mode-aware: linewise snaps to line
4822            // edges; charwise uses the actual inclusive endpoint.
4823            let rbr = match kind {
4824                RangeKind::Linewise => {
4825                    let last_col = buf_line_chars(&ed.buffer, bot.0).saturating_sub(1);
4826                    (bot.0, last_col)
4827                }
4828                RangeKind::Inclusive => (bot.0, bot.1),
4829                RangeKind::Exclusive => (bot.0, bot.1.saturating_sub(1)),
4830            };
4831            ed.set_mark('[', top);
4832            ed.set_mark(']', rbr);
4833            buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
4834            ed.push_buffer_cursor_to_textarea();
4835        }
4836        Operator::Delete => {
4837            ed.push_undo();
4838            cut_vim_range(ed, top, bot, kind);
4839            // After a charwise / inclusive delete the buffer cursor is
4840            // placed at `start` by the edit path. In Normal mode the
4841            // cursor max col is `line_len - 1`; clamp it here so e.g.
4842            // `d$` doesn't leave the cursor one past the new line end.
4843            if !matches!(kind, RangeKind::Linewise) {
4844                clamp_cursor_to_normal_mode(ed);
4845            }
4846            ed.vim.mode = Mode::Normal;
4847            // Vim `:h '[` / `:h ']`: after a delete both marks park at
4848            // the cursor position where the deletion collapsed (the join
4849            // point). Set after the cut and clamp so the position is final.
4850            let pos = ed.cursor();
4851            ed.set_mark('[', pos);
4852            ed.set_mark(']', pos);
4853        }
4854        Operator::Change => {
4855            // Vim `:h '[`: `[` is set to the start of the changed range
4856            // before the cut. `]` is deferred to insert-exit (AfterChange
4857            // path in finish_insert_session) where the cursor sits on the
4858            // last inserted char.
4859            if matches!(kind, RangeKind::Linewise) {
4860                // Linewise change (`cj`/`ck`/`cip`/`cap`/…): preserve the
4861                // first line's indent and leave exactly one row open for
4862                // insert. The helper handles push_undo + insert entry.
4863                change_linewise_rows(ed, top.0, bot.0);
4864            } else {
4865                // Charwise change: cut the range and enter insert.
4866                ed.vim.change_mark_start = Some(top);
4867                ed.push_undo();
4868                cut_vim_range(ed, top, bot, kind);
4869                begin_insert_noundo(ed, 1, InsertReason::AfterChange);
4870            }
4871        }
4872        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
4873            apply_case_op_to_selection(ed, op, top, bot, kind);
4874        }
4875        Operator::Indent | Operator::Outdent => {
4876            // Indent / outdent are always linewise even when triggered
4877            // by a char-wise motion (e.g. `>w` indents the whole line).
4878            ed.push_undo();
4879            if op == Operator::Indent {
4880                indent_rows(ed, top.0, bot.0, 1);
4881            } else {
4882                outdent_rows(ed, top.0, bot.0, 1);
4883            }
4884            ed.vim.mode = Mode::Normal;
4885        }
4886        Operator::Fold => {
4887            // Always linewise — fold the spanned rows regardless of the
4888            // motion's natural kind. Cursor lands on `top.0` to mirror
4889            // the visual `zf` path.
4890            if bot.0 >= top.0 {
4891                ed.apply_fold_op(crate::types::FoldOp::Add {
4892                    start_row: top.0,
4893                    end_row: bot.0,
4894                    closed: true,
4895                });
4896            }
4897            buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
4898            ed.push_buffer_cursor_to_textarea();
4899            ed.vim.mode = Mode::Normal;
4900        }
4901        Operator::Reflow => {
4902            ed.push_undo();
4903            reflow_rows(ed, top.0, bot.0);
4904            ed.vim.mode = Mode::Normal;
4905        }
4906        Operator::ReflowKeepCursor => {
4907            // `gw{motion}` — reflow like `gq` but restore the cursor to the
4908            // character it was on before the reflow (vim's gw behaviour).
4909            let saved = ed.cursor();
4910            ed.push_undo();
4911            let (before, after) = reflow_rows_keep_cursor(ed, top.0, bot.0);
4912            let (new_row, new_col) = reflow_keep_cursor(top.0, saved.0, saved.1, &before, &after);
4913            buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
4914            ed.push_buffer_cursor_to_textarea();
4915            ed.sticky_col = Some(new_col);
4916            ed.vim.mode = Mode::Normal;
4917        }
4918        Operator::AutoIndent => {
4919            // Always linewise — like Indent/Outdent.
4920            ed.push_undo();
4921            auto_indent_rows(ed, top.0, bot.0);
4922            ed.vim.mode = Mode::Normal;
4923        }
4924        Operator::Filter => {
4925            // Filter is not dispatched through run_operator_over_range.
4926            // The app calls Editor::filter_range directly with a command string.
4927            // Reaching this arm means a caller invoked run_operator_over_range
4928            // with Operator::Filter by mistake — silently no-op.
4929        }
4930        Operator::Comment => {
4931            // Comment is dispatched through Editor::toggle_comment_range.
4932            // Reaching this arm is a caller mistake — silently no-op.
4933        }
4934    }
4935}
4936
4937// ─── Phase 4a pub range-mutation bridges ───────────────────────────────────
4938//
4939// These are `pub(crate)` entry points called by the five new pub methods on
4940// `Editor` (`delete_range`, `yank_range`, `change_range`, `indent_range`,
4941// `case_range`). They set `pending_register` from the caller-supplied char
4942// before delegating to the existing internal helpers so register semantics
4943// (unnamed `"`, named `"a`–`"z`, delete ring) are honoured exactly as in the
4944// FSM path.
4945//
4946// Do NOT call `run_operator_over_range` for Indent/Outdent or the three case
4947// operators — those share the FSM path but have dedicated parameter shapes
4948// (signed count, Operator-as-CaseOp) that map more cleanly to their own
4949// helpers.
4950
4951/// Delete the range `[start, end)` (interpretation determined by `kind`) and
4952/// stash the deleted text in `register`. `'"'` is the unnamed register.
4953pub(crate) fn delete_range_bridge<H: crate::types::Host>(
4954    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4955    start: (usize, usize),
4956    end: (usize, usize),
4957    kind: RangeKind,
4958    register: char,
4959) {
4960    ed.vim.pending_register = Some(register);
4961    run_operator_over_range(ed, Operator::Delete, start, end, kind);
4962}
4963
4964/// Yank (copy) the range `[start, end)` into `register` without mutating the
4965/// buffer. `'"'` is the unnamed register.
4966pub(crate) fn yank_range_bridge<H: crate::types::Host>(
4967    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4968    start: (usize, usize),
4969    end: (usize, usize),
4970    kind: RangeKind,
4971    register: char,
4972) {
4973    ed.vim.pending_register = Some(register);
4974    run_operator_over_range(ed, Operator::Yank, start, end, kind);
4975}
4976
4977/// Delete the range `[start, end)` and enter Insert mode (vim `c` operator).
4978/// The deleted text is stashed in `register`. Mode transitions to Insert on
4979/// return; the caller must not issue further normal-mode ops until the insert
4980/// session ends.
4981pub(crate) fn change_range_bridge<H: crate::types::Host>(
4982    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4983    start: (usize, usize),
4984    end: (usize, usize),
4985    kind: RangeKind,
4986    register: char,
4987) {
4988    ed.vim.pending_register = Some(register);
4989    run_operator_over_range(ed, Operator::Change, start, end, kind);
4990}
4991
4992/// Indent (`count > 0`) or outdent (`count < 0`) the row span `[start.0,
4993/// end.0]`. `shiftwidth` overrides the editor's `settings().shiftwidth` for
4994/// this call; pass `0` to use the editor setting. The column parts of `start`
4995/// / `end` are ignored — indent is always linewise.
4996pub(crate) fn indent_range_bridge<H: crate::types::Host>(
4997    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4998    start: (usize, usize),
4999    end: (usize, usize),
5000    count: i32,
5001    shiftwidth: u32,
5002) {
5003    if count == 0 {
5004        return;
5005    }
5006    let (top_row, bot_row) = if start.0 <= end.0 {
5007        (start.0, end.0)
5008    } else {
5009        (end.0, start.0)
5010    };
5011    // Temporarily override shiftwidth when the caller provides one.
5012    let original_sw = ed.settings().shiftwidth;
5013    if shiftwidth > 0 {
5014        ed.settings_mut().shiftwidth = shiftwidth as usize;
5015    }
5016    ed.push_undo();
5017    let abs_count = count.unsigned_abs() as usize;
5018    if count > 0 {
5019        indent_rows(ed, top_row, bot_row, abs_count);
5020    } else {
5021        outdent_rows(ed, top_row, bot_row, abs_count);
5022    }
5023    if shiftwidth > 0 {
5024        ed.settings_mut().shiftwidth = original_sw;
5025    }
5026    ed.vim.mode = Mode::Normal;
5027}
5028
5029/// Apply a case transformation (`Uppercase` / `Lowercase` / `ToggleCase`) to
5030/// the range `[start, end)`. Only the three case `Operator` variants are valid;
5031/// other variants are silently ignored (no-op).
5032pub(crate) fn case_range_bridge<H: crate::types::Host>(
5033    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5034    start: (usize, usize),
5035    end: (usize, usize),
5036    kind: RangeKind,
5037    op: Operator,
5038) {
5039    match op {
5040        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {}
5041        _ => return,
5042    }
5043    let (top, bot) = order(start, end);
5044    apply_case_op_to_selection(ed, op, top, bot, kind);
5045}
5046
5047// ─── Phase 4e pub block-shape range-mutation bridges ───────────────────────
5048//
5049// These are `pub(crate)` entry points called by the four new pub methods on
5050// `Editor` (`delete_block`, `yank_block`, `change_block`, `indent_block`).
5051// They set `pending_register` from the caller-supplied char then delegate to
5052// `apply_block_operator` (after temporarily installing the 4-corner block as
5053// the engine's virtual VisualBlock selection). The editor's VisualBlock state
5054// fields (`block_anchor`, `block_vcol`) are overwritten, the op fires, then
5055// the fields are restored to their pre-call values. This ensures the engine's
5056// register / undo / mode semantics are exercised without requiring the caller
5057// to already be in VisualBlock mode.
5058//
5059// `indent_block` is a separate helper — it does not use `apply_block_operator`
5060// because indent/outdent are always linewise for blocks (vim behaviour).
5061
5062/// Delete a rectangular VisualBlock selection. `top_row`/`bot_row` are
5063/// inclusive line bounds; `left_col`/`right_col` are inclusive char-column
5064/// bounds. Short lines that don't reach `right_col` lose only the chars
5065/// that exist (ragged-edge, matching engine FSM). `register` is honoured;
5066/// `'"'` selects the unnamed register.
5067pub(crate) fn delete_block_bridge<H: crate::types::Host>(
5068    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5069    top_row: usize,
5070    bot_row: usize,
5071    left_col: usize,
5072    right_col: usize,
5073    register: char,
5074) {
5075    ed.vim.pending_register = Some(register);
5076    let saved_anchor = ed.vim.block_anchor;
5077    let saved_vcol = ed.vim.block_vcol;
5078    ed.vim.block_anchor = (top_row, left_col);
5079    ed.vim.block_vcol = right_col;
5080    // Compute clamped col before the mutable borrow for buf_set_cursor_rc.
5081    let clamped = right_col.min(buf_line_chars(&ed.buffer, bot_row).saturating_sub(1));
5082    // Place cursor at bot_row / right_col so block_bounds resolves correctly.
5083    buf_set_cursor_rc(&mut ed.buffer, bot_row, clamped);
5084    apply_block_operator(ed, Operator::Delete);
5085    // Restore — block_anchor/vcol are only meaningful in VisualBlock mode;
5086    // after the op we're in Normal so restoring is a no-op for the user but
5087    // keeps state coherent if the caller inspects fields.
5088    ed.vim.block_anchor = saved_anchor;
5089    ed.vim.block_vcol = saved_vcol;
5090}
5091
5092/// Yank a rectangular VisualBlock selection into `register`.
5093pub(crate) fn yank_block_bridge<H: crate::types::Host>(
5094    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5095    top_row: usize,
5096    bot_row: usize,
5097    left_col: usize,
5098    right_col: usize,
5099    register: char,
5100) {
5101    ed.vim.pending_register = Some(register);
5102    let saved_anchor = ed.vim.block_anchor;
5103    let saved_vcol = ed.vim.block_vcol;
5104    ed.vim.block_anchor = (top_row, left_col);
5105    ed.vim.block_vcol = right_col;
5106    let clamped = right_col.min(buf_line_chars(&ed.buffer, bot_row).saturating_sub(1));
5107    buf_set_cursor_rc(&mut ed.buffer, bot_row, clamped);
5108    apply_block_operator(ed, Operator::Yank);
5109    ed.vim.block_anchor = saved_anchor;
5110    ed.vim.block_vcol = saved_vcol;
5111}
5112
5113/// Delete a rectangular VisualBlock selection and enter Insert mode (`c`).
5114/// The deleted text is stashed in `register`. Mode is Insert on return.
5115pub(crate) fn change_block_bridge<H: crate::types::Host>(
5116    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5117    top_row: usize,
5118    bot_row: usize,
5119    left_col: usize,
5120    right_col: usize,
5121    register: char,
5122) {
5123    ed.vim.pending_register = Some(register);
5124    let saved_anchor = ed.vim.block_anchor;
5125    let saved_vcol = ed.vim.block_vcol;
5126    ed.vim.block_anchor = (top_row, left_col);
5127    ed.vim.block_vcol = right_col;
5128    let clamped = right_col.min(buf_line_chars(&ed.buffer, bot_row).saturating_sub(1));
5129    buf_set_cursor_rc(&mut ed.buffer, bot_row, clamped);
5130    apply_block_operator(ed, Operator::Change);
5131    ed.vim.block_anchor = saved_anchor;
5132    ed.vim.block_vcol = saved_vcol;
5133}
5134
5135/// Indent (`count > 0`) or outdent (`count < 0`) rows `top_row..=bot_row`.
5136/// Column bounds are ignored — vim's block indent is always linewise.
5137/// `count == 0` is a no-op.
5138pub(crate) fn indent_block_bridge<H: crate::types::Host>(
5139    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5140    top_row: usize,
5141    bot_row: usize,
5142    count: i32,
5143) {
5144    if count == 0 {
5145        return;
5146    }
5147    ed.push_undo();
5148    let abs = count.unsigned_abs() as usize;
5149    if count > 0 {
5150        indent_rows(ed, top_row, bot_row, abs);
5151    } else {
5152        outdent_rows(ed, top_row, bot_row, abs);
5153    }
5154    ed.vim.mode = Mode::Normal;
5155}
5156
5157/// Auto-indent (v1 dumb shiftwidth) the row span `[start.0, end.0]`. Column
5158/// parts are ignored — auto-indent is always linewise. See
5159/// `auto_indent_rows` for the algorithm and its v1 limitations.
5160pub(crate) fn auto_indent_range_bridge<H: crate::types::Host>(
5161    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5162    start: (usize, usize),
5163    end: (usize, usize),
5164) {
5165    let (top_row, bot_row) = if start.0 <= end.0 {
5166        (start.0, end.0)
5167    } else {
5168        (end.0, start.0)
5169    };
5170    ed.push_undo();
5171    auto_indent_rows(ed, top_row, bot_row);
5172    ed.vim.mode = Mode::Normal;
5173}
5174
5175// ─── Phase 4b pub text-object resolution bridges ───────────────────────────
5176//
5177// These are `pub(crate)` entry points called by the four new pub methods on
5178// `Editor` (`text_object_inner_word`, `text_object_around_word`,
5179// `text_object_inner_big_word`, `text_object_around_big_word`). They delegate
5180// to `word_text_object` — the existing private resolver — without touching any
5181// operator, register, or mode state. Pure functions: only `&Editor` required.
5182
5183/// Resolve the range of `iw` (inner word) at the current cursor position.
5184/// Returns `None` if no word exists at the cursor.
5185pub(crate) fn text_object_inner_word_bridge<H: crate::types::Host>(
5186    ed: &Editor<hjkl_buffer::Buffer, H>,
5187) -> Option<((usize, usize), (usize, usize))> {
5188    word_text_object(ed, true, false)
5189}
5190
5191/// Resolve the range of `aw` (around word) at the current cursor position.
5192/// Includes trailing whitespace (or leading whitespace if no trailing exists).
5193pub(crate) fn text_object_around_word_bridge<H: crate::types::Host>(
5194    ed: &Editor<hjkl_buffer::Buffer, H>,
5195) -> Option<((usize, usize), (usize, usize))> {
5196    word_text_object(ed, false, false)
5197}
5198
5199/// Resolve the range of `iW` (inner WORD) at the current cursor position.
5200/// A WORD is any run of non-whitespace characters (no punctuation splitting).
5201pub(crate) fn text_object_inner_big_word_bridge<H: crate::types::Host>(
5202    ed: &Editor<hjkl_buffer::Buffer, H>,
5203) -> Option<((usize, usize), (usize, usize))> {
5204    word_text_object(ed, true, true)
5205}
5206
5207/// Resolve the range of `aW` (around WORD) at the current cursor position.
5208/// Includes trailing whitespace (or leading whitespace if no trailing exists).
5209pub(crate) fn text_object_around_big_word_bridge<H: crate::types::Host>(
5210    ed: &Editor<hjkl_buffer::Buffer, H>,
5211) -> Option<((usize, usize), (usize, usize))> {
5212    word_text_object(ed, false, true)
5213}
5214
5215// ─── Phase 4c pub text-object resolution bridges (quote + bracket) ──────────
5216//
5217// `pub(crate)` entry points called by the four new pub methods on `Editor`
5218// (`text_object_inner_quote`, `text_object_around_quote`,
5219// `text_object_inner_bracket`, `text_object_around_bracket`). They delegate to
5220// `quote_text_object` / `bracket_text_object` — the existing private resolvers
5221// — without touching any operator, register, or mode state.
5222//
5223// `bracket_text_object` returns `Option<(Pos, Pos, RangeKind)>`; the bridges
5224// strip the `RangeKind` tag so callers see a uniform
5225// `Option<((usize,usize),(usize,usize))>` shape, consistent with 4b.
5226
5227/// Resolve the range of `i<quote>` (inner quote) at the current cursor
5228/// position. `quote` is one of `'"'`, `'\''`, or `` '`' ``. Returns `None`
5229/// when the cursor's line contains fewer than two occurrences of `quote`.
5230pub(crate) fn text_object_inner_quote_bridge<H: crate::types::Host>(
5231    ed: &Editor<hjkl_buffer::Buffer, H>,
5232    quote: char,
5233) -> Option<((usize, usize), (usize, usize))> {
5234    quote_text_object(ed, quote, true)
5235}
5236
5237/// Resolve the range of `a<quote>` (around quote) at the current cursor
5238/// position. Includes surrounding whitespace on one side per vim semantics.
5239pub(crate) fn text_object_around_quote_bridge<H: crate::types::Host>(
5240    ed: &Editor<hjkl_buffer::Buffer, H>,
5241    quote: char,
5242) -> Option<((usize, usize), (usize, usize))> {
5243    quote_text_object(ed, quote, false)
5244}
5245
5246/// Resolve the range of `i<bracket>` (inner bracket pair). `open` must be
5247/// one of `'('`, `'{'`, `'['`, `'<'`; the corresponding close is derived
5248/// internally. Returns `None` when no enclosing pair is found. The returned
5249/// range excludes the bracket characters themselves. Multi-line bracket pairs
5250/// whose content spans more than one line are reported as a charwise range
5251/// covering the first content character through the last content character
5252/// (RangeKind metadata is stripped — callers receive start/end only).
5253pub(crate) fn text_object_inner_bracket_bridge<H: crate::types::Host>(
5254    ed: &Editor<hjkl_buffer::Buffer, H>,
5255    open: char,
5256) -> Option<((usize, usize), (usize, usize))> {
5257    bracket_text_object(ed, open, true, 1).map(|(s, e, _kind)| (s, e))
5258}
5259
5260/// Resolve the range of `a<bracket>` (around bracket pair). Includes the
5261/// bracket characters themselves. `open` must be one of `'('`, `'{'`, `'['`,
5262/// `'<'`.
5263pub(crate) fn text_object_around_bracket_bridge<H: crate::types::Host>(
5264    ed: &Editor<hjkl_buffer::Buffer, H>,
5265    open: char,
5266) -> Option<((usize, usize), (usize, usize))> {
5267    bracket_text_object(ed, open, false, 1).map(|(s, e, _kind)| (s, e))
5268}
5269
5270// ── Sentence bridges (is / as) ─────────────────────────────────────────────
5271
5272/// Resolve the range of `is` (inner sentence) at the cursor. Excludes
5273/// trailing whitespace.
5274pub(crate) fn text_object_inner_sentence_bridge<H: crate::types::Host>(
5275    ed: &Editor<hjkl_buffer::Buffer, H>,
5276) -> Option<((usize, usize), (usize, usize))> {
5277    sentence_text_object(ed, true)
5278}
5279
5280/// Resolve the range of `as` (around sentence) at the cursor. Includes
5281/// trailing whitespace.
5282pub(crate) fn text_object_around_sentence_bridge<H: crate::types::Host>(
5283    ed: &Editor<hjkl_buffer::Buffer, H>,
5284) -> Option<((usize, usize), (usize, usize))> {
5285    sentence_text_object(ed, false)
5286}
5287
5288// ── Paragraph bridges (ip / ap) ────────────────────────────────────────────
5289
5290/// Resolve the range of `ip` (inner paragraph) at the cursor. A paragraph
5291/// is a block of non-blank lines bounded by blank lines or buffer edges.
5292pub(crate) fn text_object_inner_paragraph_bridge<H: crate::types::Host>(
5293    ed: &Editor<hjkl_buffer::Buffer, H>,
5294) -> Option<((usize, usize), (usize, usize))> {
5295    paragraph_text_object(ed, true)
5296}
5297
5298/// Resolve the range of `ap` (around paragraph) at the cursor. Includes one
5299/// trailing blank line when present.
5300pub(crate) fn text_object_around_paragraph_bridge<H: crate::types::Host>(
5301    ed: &Editor<hjkl_buffer::Buffer, H>,
5302) -> Option<((usize, usize), (usize, usize))> {
5303    paragraph_text_object(ed, false)
5304}
5305
5306// ── Tag bridges (it / at) ──────────────────────────────────────────────────
5307
5308/// Resolve the range of `it` (inner tag) at the cursor. Matches XML/HTML-style
5309/// `<tag>...</tag>` pairs; returns the range of inner content between the open
5310/// and close tags.
5311pub(crate) fn text_object_inner_tag_bridge<H: crate::types::Host>(
5312    ed: &Editor<hjkl_buffer::Buffer, H>,
5313) -> Option<((usize, usize), (usize, usize))> {
5314    tag_text_object(ed, true)
5315}
5316
5317/// Resolve the range of `at` (around tag) at the cursor. Includes the open
5318/// and close tag delimiters themselves.
5319pub(crate) fn text_object_around_tag_bridge<H: crate::types::Host>(
5320    ed: &Editor<hjkl_buffer::Buffer, H>,
5321) -> Option<((usize, usize), (usize, usize))> {
5322    tag_text_object(ed, false)
5323}
5324
5325// ─── Rope utility helpers ──────────────────────────────────────────────────
5326
5327/// Return row `r` from a rope as an owned `String`, stripping the
5328/// trailing `\n` that ropey includes on non-final lines.
5329pub(crate) fn rope_line_to_str(rope: &ropey::Rope, r: usize) -> String {
5330    let s = rope.line(r).to_string();
5331    // ropey includes the newline; strip it so callers see bare content.
5332    if s.ends_with('\n') {
5333        s[..s.len() - 1].to_string()
5334    } else {
5335        s
5336    }
5337}
5338
5339/// Join rows `lo..=hi` from a rope into a single `String` separated by
5340/// `\n`. Callers must ensure `lo <= hi < rope.len_lines()`.
5341pub(crate) fn rope_row_range_str(rope: &ropey::Rope, lo: usize, hi: usize) -> String {
5342    let n = rope.len_lines();
5343    let lo = lo.min(n.saturating_sub(1));
5344    let hi = hi.min(n.saturating_sub(1));
5345    if lo > hi {
5346        return String::new();
5347    }
5348    // Use byte-slice to grab the full range in one rope walk.
5349    let start_byte = rope.line_to_byte(lo);
5350    // End byte: start of line hi+1, minus the newline separator, or
5351    // len_bytes() when hi is the last line.
5352    let end_byte = if hi + 1 < n {
5353        // line_to_byte(hi+1) points at the \n-terminated start of
5354        // the next line; step back one byte to drop that trailing \n.
5355        rope.line_to_byte(hi + 1).saturating_sub(1)
5356    } else {
5357        rope.len_bytes()
5358    };
5359    rope.byte_slice(start_byte..end_byte).to_string()
5360}
5361
5362/// Snapshot all rows from a rope as `Vec<String>` (no trailing `\n`).
5363/// Use only when the caller truly needs mutable per-row access; prefer
5364/// rope iterators otherwise.
5365pub(crate) fn rope_to_lines_vec(rope: &ropey::Rope) -> Vec<String> {
5366    let n = rope.len_lines();
5367    (0..n).map(|r| rope_line_to_str(rope, r)).collect()
5368}
5369
5370/// Pure greedy word-wrap of a slice of lines to `width` chars.
5371/// Returns `(original_slice, wrapped_lines)`.
5372/// Blank lines are preserved as paragraph separators.
5373fn greedy_wrap(original: &[String], width: usize) -> Vec<String> {
5374    let mut wrapped: Vec<String> = Vec::new();
5375    let mut paragraph: Vec<String> = Vec::new();
5376    let flush = |para: &mut Vec<String>, out: &mut Vec<String>, width: usize| {
5377        if para.is_empty() {
5378            return;
5379        }
5380        let words = para.join(" ");
5381        let mut current = String::new();
5382        for word in words.split_whitespace() {
5383            let extra = if current.is_empty() {
5384                word.chars().count()
5385            } else {
5386                current.chars().count() + 1 + word.chars().count()
5387            };
5388            if extra > width && !current.is_empty() {
5389                out.push(std::mem::take(&mut current));
5390                current.push_str(word);
5391            } else if current.is_empty() {
5392                current.push_str(word);
5393            } else {
5394                current.push(' ');
5395                current.push_str(word);
5396            }
5397        }
5398        if !current.is_empty() {
5399            out.push(current);
5400        }
5401        para.clear();
5402    };
5403    for line in original {
5404        if line.trim().is_empty() {
5405            flush(&mut paragraph, &mut wrapped, width);
5406            wrapped.push(String::new());
5407        } else {
5408            paragraph.push(line.clone());
5409        }
5410    }
5411    flush(&mut paragraph, &mut wrapped, width);
5412    wrapped
5413}
5414
5415/// Greedy word-wrap the rows in `[top, bot]` to `settings.textwidth`.
5416/// Splits on blank-line boundaries so paragraph structure is
5417/// preserved. Each paragraph's words are joined with single spaces
5418/// before re-wrapping. Cursor lands at `(top, 0)` after the call
5419/// (via `ed.restore`).
5420fn reflow_rows<H: crate::types::Host>(
5421    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5422    top: usize,
5423    bot: usize,
5424) {
5425    let width = ed.settings().textwidth.max(1);
5426    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
5427    let bot = bot.min(lines.len().saturating_sub(1));
5428    if top > bot {
5429        return;
5430    }
5431    let original = lines[top..=bot].to_vec();
5432    let wrapped = greedy_wrap(&original, width);
5433
5434    // Splice back. push_undo above means `u` reverses.
5435    let after: Vec<String> = lines.split_off(bot + 1);
5436    lines.truncate(top);
5437    lines.extend(wrapped);
5438    lines.extend(after);
5439    ed.restore(lines, (top, 0));
5440    ed.mark_content_dirty();
5441}
5442
5443/// Same reflow as `reflow_rows` but also returns the pre-reflow slice
5444/// and the wrapped lines so the caller can compute a character-preserving
5445/// cursor position via [`reflow_keep_cursor`].
5446fn reflow_rows_keep_cursor<H: crate::types::Host>(
5447    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5448    top: usize,
5449    bot: usize,
5450) -> (Vec<String>, Vec<String>) {
5451    let width = ed.settings().textwidth.max(1);
5452    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
5453    let bot = bot.min(lines.len().saturating_sub(1));
5454    if top > bot {
5455        return (Vec::new(), Vec::new());
5456    }
5457    let original = lines[top..=bot].to_vec();
5458    let wrapped = greedy_wrap(&original, width);
5459
5460    let after: Vec<String> = lines.split_off(bot + 1);
5461    lines.truncate(top);
5462    lines.extend(wrapped.clone());
5463    lines.extend(after);
5464    ed.restore(lines, (top, 0));
5465    ed.mark_content_dirty();
5466    (original, wrapped)
5467}
5468
5469/// Compute the new `(row, col)` that preserves the character the cursor
5470/// was on after `reflow_rows` has been applied to `[top, bot]`.
5471///
5472/// Algorithm (mirrors nvim's `gw` behaviour):
5473/// 1. Count the char-index of `(cursor_row, cursor_col)` relative to the
5474///    start of line `top` in `before_lines` (the pre-reflow snapshot).
5475/// 2. Walk the `after_lines` (the wrapped output) to find the row/col
5476///    that has the same char index.
5477///
5478/// If the cursor was past the end of the reflowed content (e.g. beyond
5479/// the last char), we clamp to the last char of the last reflowed line.
5480fn reflow_keep_cursor(
5481    top: usize,
5482    cursor_row: usize,
5483    cursor_col: usize,
5484    before_lines: &[String],
5485    after_lines: &[String],
5486) -> (usize, usize) {
5487    // Char offset of cursor within the before_lines range.
5488    // Each line contributes its chars; lines are separated by a single
5489    // space in the collapsed paragraph — but since reflow joins everything
5490    // and re-wraps with spaces, counting by chars-per-line (plus the
5491    // conceptual space separator between lines) mirrors the join.
5492    //
5493    // The simpler approach (which nvim appears to use): the cursor offset
5494    // within the range is the sum of chars in lines before cursor_row
5495    // (each + 1 for the space/newline separator) plus cursor_col, then
5496    // find that position in the wrapped text.
5497    //
5498    // Actually, since reflow collapses whitespace (split_whitespace),
5499    // the simplest approach is to track the cursor's char in the ORIGINAL
5500    // concatenated text and find it in the reflowed text.
5501
5502    // Build the original range text as it appears when joined for wrapping:
5503    // same as what reflow does internally — join with spaces.
5504    // But we want raw character index, so we accumulate char counts per line
5505    // (without the trailing newline).
5506    let relative_row = cursor_row.saturating_sub(top);
5507    let mut char_offset: usize = 0;
5508    for (i, line) in before_lines.iter().enumerate() {
5509        if i == relative_row {
5510            // Add clamped col within this line.
5511            let line_len = line.chars().count();
5512            char_offset += cursor_col.min(line_len);
5513            break;
5514        }
5515        // Each line contributes its chars plus a newline (or space boundary).
5516        char_offset += line.chars().count() + 1;
5517    }
5518
5519    // Now find char_offset in after_lines.
5520    let mut remaining = char_offset;
5521    for (i, line) in after_lines.iter().enumerate() {
5522        let len = line.chars().count();
5523        if remaining <= len {
5524            // The col is clamped to line_len - 1 in Normal mode.
5525            let col = remaining.min(if len == 0 { 0 } else { len.saturating_sub(1) });
5526            return (top + i, col);
5527        }
5528        // Not on this line; subtract line len + 1 (newline separator).
5529        remaining = remaining.saturating_sub(len + 1);
5530    }
5531
5532    // Cursor was beyond the end of the reflowed content — clamp to last line.
5533    let last = after_lines.len().saturating_sub(1);
5534    let last_len = after_lines
5535        .get(last)
5536        .map(|l| l.chars().count())
5537        .unwrap_or(0);
5538    let col = if last_len == 0 { 0 } else { last_len - 1 };
5539    (top + last, col)
5540}
5541
5542/// Transform the range `[top, bot]` (vim `RangeKind`) in place with
5543/// the given case operator. Cursor lands on `top` afterward — vim
5544/// convention for `gU{motion}` / `gu{motion}` / `g~{motion}`.
5545/// Preserves the textarea yank buffer (vim's case operators don't
5546/// touch registers).
5547fn apply_case_op_to_selection<H: crate::types::Host>(
5548    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5549    op: Operator,
5550    top: (usize, usize),
5551    bot: (usize, usize),
5552    kind: RangeKind,
5553) {
5554    use hjkl_buffer::Edit;
5555    ed.push_undo();
5556    let saved_yank = ed.yank().to_string();
5557    let saved_yank_linewise = ed.vim.yank_linewise;
5558    let selection = cut_vim_range(ed, top, bot, kind);
5559    let transformed = match op {
5560        Operator::Uppercase => selection.to_uppercase(),
5561        Operator::Lowercase => selection.to_lowercase(),
5562        Operator::ToggleCase => toggle_case_str(&selection),
5563        _ => unreachable!(),
5564    };
5565    if !transformed.is_empty() {
5566        let cursor = buf_cursor_pos(&ed.buffer);
5567        ed.mutate_edit(Edit::InsertStr {
5568            at: cursor,
5569            text: transformed,
5570        });
5571    }
5572    buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
5573    ed.push_buffer_cursor_to_textarea();
5574    ed.set_yank(saved_yank);
5575    ed.vim.yank_linewise = saved_yank_linewise;
5576    ed.vim.mode = Mode::Normal;
5577}
5578
5579/// Prepend `count * shiftwidth` spaces to each row in `[top, bot]`.
5580/// Rows that are empty are skipped (vim leaves blank lines alone when
5581/// indenting). `shiftwidth` is read from `editor.settings()` so
5582/// `:set shiftwidth=N` takes effect on the next operation.
5583fn indent_rows<H: crate::types::Host>(
5584    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5585    top: usize,
5586    bot: usize,
5587    count: usize,
5588) {
5589    ed.sync_buffer_content_from_textarea();
5590    let width = ed.settings().shiftwidth * count.max(1);
5591    let pad: String = " ".repeat(width);
5592    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
5593    let bot = bot.min(lines.len().saturating_sub(1));
5594    for line in lines.iter_mut().take(bot + 1).skip(top) {
5595        if !line.is_empty() {
5596            line.insert_str(0, &pad);
5597        }
5598    }
5599    // Restore cursor to first non-blank of the top row so the next
5600    // vertical motion aims sensibly — matches vim's `>>` convention.
5601    ed.restore(lines, (top, 0));
5602    move_first_non_whitespace(ed);
5603}
5604
5605/// Remove up to `count * shiftwidth` leading spaces (or tabs) from
5606/// each row in `[top, bot]`. Rows with less leading whitespace have
5607/// all their indent stripped, not clipped to zero length.
5608fn outdent_rows<H: crate::types::Host>(
5609    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5610    top: usize,
5611    bot: usize,
5612    count: usize,
5613) {
5614    ed.sync_buffer_content_from_textarea();
5615    let width = ed.settings().shiftwidth * count.max(1);
5616    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
5617    let bot = bot.min(lines.len().saturating_sub(1));
5618    for line in lines.iter_mut().take(bot + 1).skip(top) {
5619        let strip: usize = line
5620            .chars()
5621            .take(width)
5622            .take_while(|c| *c == ' ' || *c == '\t')
5623            .count();
5624        if strip > 0 {
5625            let byte_len: usize = line.chars().take(strip).map(|c| c.len_utf8()).sum();
5626            line.drain(..byte_len);
5627        }
5628    }
5629    ed.restore(lines, (top, 0));
5630    move_first_non_whitespace(ed);
5631}
5632
5633/// Count the number of open/close bracket pairs on a single line for the
5634/// auto-indent depth scanner. Only bare bracket scanning — does NOT handle
5635/// string literals or comments (v1 limitation, documented on
5636/// `auto_indent_range_bridge`).
5637/// Net bracket count `(open - close)` for a single line, skipping
5638/// brackets inside `//` line comments, `"..."` string literals, and
5639/// `'X'` char literals.
5640///
5641/// String / char escapes (`\"`, `\'`, `\\`) are honored so the closing
5642/// quote isn't missed when the literal contains a backslash.
5643///
5644/// Limitations:
5645/// - Block comments `/* ... */` are NOT tracked across lines (a single
5646///   line `/* foo { bar } */` is correctly skipped only because the
5647///   `/*` and `*/` are on the same line and we'd see `{` after `/*`).
5648///   For v1 we leave this since block comments mid-code are rare.
5649/// - Raw string literals `r"..."` / `r#"..."#` are NOT special-cased.
5650/// - Lifetime annotations like `'a` look like an unterminated char
5651///   literal — handled by the heuristic that a char literal MUST close
5652///   within the line; if the closing `'` isn't found, treat the `'` as
5653///   a normal character (lifetime).
5654///
5655/// Pre-fix the scan was naive — `//! ... }` on a doc comment
5656/// decremented depth, cascading wrong indentation through the rest of
5657/// the file. This caused ~19% of lines to mis-indent on a real Rust
5658/// source diagnostic.
5659fn bracket_net(line: &str) -> i32 {
5660    let mut net: i32 = 0;
5661    let mut chars = line.chars().peekable();
5662    while let Some(ch) = chars.next() {
5663        match ch {
5664            // `//` → rest of line is a comment, stop.
5665            '/' if chars.peek() == Some(&'/') => return net,
5666            '"' => {
5667                // String literal — consume until unescaped closing `"`.
5668                while let Some(c) = chars.next() {
5669                    match c {
5670                        '\\' => {
5671                            chars.next();
5672                        } // skip escape byte
5673                        '"' => break,
5674                        _ => {}
5675                    }
5676                }
5677            }
5678            '\'' => {
5679                // Char literal OR lifetime. A char literal closes within
5680                // a few chars (one or two for escapes). A lifetime is
5681                // `'ident` with no closing quote.
5682                //
5683                // Strategy: peek ahead for a closing `'`. If found
5684                // within ~4 chars, consume as char literal. Otherwise
5685                // treat the `'` as the start of a lifetime — leave the
5686                // remaining chars to be scanned normally.
5687                let saved: Vec<char> = chars.clone().take(5).collect();
5688                let close_idx = if saved.first() == Some(&'\\') {
5689                    saved.iter().skip(2).position(|&c| c == '\'').map(|p| p + 2)
5690                } else {
5691                    saved.iter().skip(1).position(|&c| c == '\'').map(|p| p + 1)
5692                };
5693                if let Some(idx) = close_idx {
5694                    for _ in 0..=idx {
5695                        chars.next();
5696                    }
5697                }
5698                // If no close found, leave chars alone — lifetime path.
5699            }
5700            '{' | '(' | '[' => net += 1,
5701            '}' | ')' | ']' => net -= 1,
5702            _ => {}
5703        }
5704    }
5705    net
5706}
5707
5708/// Reindent rows `[top, bot]` using shiftwidth-based bracket-depth counting.
5709///
5710/// The indent for each line is computed as follows:
5711/// 1. Scan all rows from 0 up to the target row, accumulating a bracket depth
5712///    (`depth`) from net open − close brackets per line. The scan starts at row
5713///    0 to give correct depth for code that appears mid-buffer.
5714/// 2. For the target line, peek at its first non-whitespace character:
5715///    if it is a close bracket (`}`, `)`, `]`) then `effective_depth =
5716///    depth.saturating_sub(1)`; otherwise `effective_depth = depth`.
5717/// 3. Strip the line's existing leading whitespace and prepend
5718///    `effective_depth × indent_unit` where `indent_unit` is `"\t"` when
5719///    `expandtab == false` or `" " × shiftwidth` when `expandtab == true`.
5720/// 4. Empty / whitespace-only lines are left empty (no trailing whitespace).
5721/// 5. After computing the new line, advance `depth` by the line's bracket
5722///    net count (open − close), where the leading close-bracket already
5723///    contributed `−1` to the net of its own line.
5724///
5725/// **v1 limitation**: the bracket scan is naive — it does not skip brackets
5726/// inside string literals (`"{"`, `'['`) or comments (`// {`). Code with
5727/// such patterns will produce incorrect indent depths. Tree-sitter / LSP
5728/// indentation is deferred to a follow-up.
5729fn auto_indent_rows<H: crate::types::Host>(
5730    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5731    top: usize,
5732    bot: usize,
5733) {
5734    ed.sync_buffer_content_from_textarea();
5735    let shiftwidth = ed.settings().shiftwidth;
5736    let expandtab = ed.settings().expandtab;
5737    let indent_unit: String = if expandtab {
5738        " ".repeat(shiftwidth)
5739    } else {
5740        "\t".to_string()
5741    };
5742
5743    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
5744    let bot = bot.min(lines.len().saturating_sub(1));
5745
5746    // Accumulate bracket depth from row 0 up to `top - 1` so we start with
5747    // the correct depth for the first line of the target range.
5748    let mut depth: i32 = 0;
5749    for line in lines.iter().take(top) {
5750        depth += bracket_net(line);
5751        if depth < 0 {
5752            depth = 0;
5753        }
5754    }
5755
5756    for line in lines.iter_mut().take(bot + 1).skip(top) {
5757        let trimmed_owned = line.trim_start().to_owned();
5758        // Empty / whitespace-only lines stay empty.
5759        if trimmed_owned.is_empty() {
5760            *line = String::new();
5761            // depth contribution from an empty line is zero; no bracket scan needed.
5762            continue;
5763        }
5764
5765        // Detect leading close-bracket for effective depth.
5766        let starts_with_close = trimmed_owned
5767            .chars()
5768            .next()
5769            .is_some_and(|c| matches!(c, '}' | ')' | ']'));
5770        // Chain continuation: a line starting with `.` (e.g. `.foo()`)
5771        // hangs off the previous expression and gets one extra indent
5772        // level, matching cargo fmt / clang-format conventions for
5773        // method chains like:
5774        //   let x = foo()
5775        //       .bar()
5776        //       .baz();
5777        // Range expressions (`..`) and try-chains (`?.`) are out of
5778        // scope for v1 — single leading `.` is the common case.
5779        let starts_with_dot = trimmed_owned.starts_with('.')
5780            && !trimmed_owned.starts_with("..")
5781            && !trimmed_owned.starts_with(".;");
5782        let effective_depth = if starts_with_close {
5783            depth.saturating_sub(1)
5784        } else if starts_with_dot {
5785            depth.saturating_add(1)
5786        } else {
5787            depth
5788        } as usize;
5789
5790        // Build new line: indent × depth + stripped content.
5791        let new_line = format!("{}{}", indent_unit.repeat(effective_depth), trimmed_owned);
5792
5793        // Advance depth by this line's net bracket count (scan trimmed content).
5794        depth += bracket_net(&trimmed_owned);
5795        if depth < 0 {
5796            depth = 0;
5797        }
5798
5799        *line = new_line;
5800    }
5801
5802    // Restore cursor to the first non-blank of `top` (vim parity for `==`).
5803    ed.restore(lines, (top, 0));
5804    move_first_non_whitespace(ed);
5805    // Record the touched row range so the host can display a visual flash.
5806    ed.last_indent_range = Some((top, bot));
5807}
5808
5809fn toggle_case_str(s: &str) -> String {
5810    s.chars()
5811        .map(|c| {
5812            if c.is_lowercase() {
5813                c.to_uppercase().next().unwrap_or(c)
5814            } else if c.is_uppercase() {
5815                c.to_lowercase().next().unwrap_or(c)
5816            } else {
5817                c
5818            }
5819        })
5820        .collect()
5821}
5822
5823fn order(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) {
5824    if a <= b { (a, b) } else { (b, a) }
5825}
5826
5827/// Clamp the buffer cursor to normal-mode valid position: col may not
5828/// exceed `line.chars().count().saturating_sub(1)` (or 0 on an empty
5829/// line). Vim applies this clamp on every return to Normal mode after an
5830/// operator or Esc-from-insert.
5831fn clamp_cursor_to_normal_mode<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5832    let (row, col) = ed.cursor();
5833    let line_chars = buf_line_chars(&ed.buffer, row);
5834    let max_col = line_chars.saturating_sub(1);
5835    if col > max_col {
5836        buf_set_cursor_rc(&mut ed.buffer, row, max_col);
5837        ed.push_buffer_cursor_to_textarea();
5838    }
5839}
5840
5841// ─── dd/cc/yy ──────────────────────────────────────────────────────────────
5842
5843fn execute_line_op<H: crate::types::Host>(
5844    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5845    op: Operator,
5846    count: usize,
5847) {
5848    let (row, col) = ed.cursor();
5849    let total = buf_row_count(&ed.buffer);
5850    let end_row = (row + count.saturating_sub(1)).min(total.saturating_sub(1));
5851
5852    match op {
5853        Operator::Yank => {
5854            // yy must not move the cursor.
5855            let text = read_vim_range(ed, (row, col), (end_row, 0), RangeKind::Linewise);
5856            if !text.is_empty() {
5857                ed.record_yank_to_host(text.clone());
5858                ed.record_yank(text, true);
5859            }
5860            // Vim `:h '[` / `:h ']`: yy/Nyy — linewise yank; `[` =
5861            // (top_row, 0), `]` = (bot_row, last_col).
5862            let last_col = buf_line_chars(&ed.buffer, end_row).saturating_sub(1);
5863            ed.set_mark('[', (row, 0));
5864            ed.set_mark(']', (end_row, last_col));
5865            buf_set_cursor_rc(&mut ed.buffer, row, col);
5866            ed.push_buffer_cursor_to_textarea();
5867            ed.vim.mode = Mode::Normal;
5868        }
5869        Operator::Delete => {
5870            ed.push_undo();
5871            let deleted_through_last = end_row + 1 >= total;
5872            cut_vim_range(ed, (row, col), (end_row, 0), RangeKind::Linewise);
5873            // Vim's `dd` / `Ndd` leaves the cursor on the *first
5874            // non-blank* of the line that now occupies `row` — or, if
5875            // the deletion consumed the last line, the line above it.
5876            let total_after = buf_row_count(&ed.buffer);
5877            let raw_target = if deleted_through_last {
5878                row.saturating_sub(1).min(total_after.saturating_sub(1))
5879            } else {
5880                row.min(total_after.saturating_sub(1))
5881            };
5882            // Clamp off the trailing phantom empty row that arises from a
5883            // buffer with a trailing newline (stored as ["...", ""]). If
5884            // the target row is the trailing empty row and there is a real
5885            // content row above it, use that instead — matching vim's view
5886            // that the trailing `\n` is a terminator, not a separator.
5887            let target_row = if raw_target > 0
5888                && raw_target + 1 == total_after
5889                && buf_line(&ed.buffer, raw_target)
5890                    .map(|s| s.is_empty())
5891                    .unwrap_or(false)
5892            {
5893                raw_target - 1
5894            } else {
5895                raw_target
5896            };
5897            buf_set_cursor_rc(&mut ed.buffer, target_row, 0);
5898            ed.push_buffer_cursor_to_textarea();
5899            move_first_non_whitespace(ed);
5900            ed.sticky_col = Some(ed.cursor().1);
5901            ed.vim.mode = Mode::Normal;
5902            // Vim `:h '[` / `:h ']`: dd/Ndd — both marks park at the
5903            // post-delete cursor position (the join point).
5904            let pos = ed.cursor();
5905            ed.set_mark('[', pos);
5906            ed.set_mark(']', pos);
5907        }
5908        Operator::Change => {
5909            // `cc` / `3cc`: delegate to the shared linewise-change helper
5910            // which preserves the first line's indent, leaves one row open,
5911            // and enters insert mode.
5912            change_linewise_rows(ed, row, end_row);
5913        }
5914        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
5915            // `gUU` / `guu` / `g~~` — linewise case transform over
5916            // [row, end_row]. Preserve cursor on `row` (first non-blank
5917            // lines up with vim's behaviour).
5918            apply_case_op_to_selection(ed, op, (row, col), (end_row, 0), RangeKind::Linewise);
5919            // After case-op on a linewise range vim puts the cursor on
5920            // the first non-blank of the starting line.
5921            move_first_non_whitespace(ed);
5922        }
5923        Operator::Indent | Operator::Outdent => {
5924            // `>>` / `N>>` / `<<` / `N<<` — linewise indent / outdent.
5925            ed.push_undo();
5926            if op == Operator::Indent {
5927                indent_rows(ed, row, end_row, 1);
5928            } else {
5929                outdent_rows(ed, row, end_row, 1);
5930            }
5931            ed.sticky_col = Some(ed.cursor().1);
5932            ed.vim.mode = Mode::Normal;
5933        }
5934        // No doubled form — `zfzf` is two consecutive `zf` chords.
5935        Operator::Fold => unreachable!("Fold has no line-op double"),
5936        Operator::Reflow => {
5937            // `gqq` / `Ngqq` — reflow `count` rows starting at the cursor.
5938            ed.push_undo();
5939            reflow_rows(ed, row, end_row);
5940            move_first_non_whitespace(ed);
5941            ed.sticky_col = Some(ed.cursor().1);
5942            ed.vim.mode = Mode::Normal;
5943        }
5944        Operator::ReflowKeepCursor => {
5945            // `gww` / `Ngww` — reflow `count` rows starting at the cursor,
5946            // but leave the cursor at the character it was on before reflow.
5947            let saved = ed.cursor();
5948            ed.push_undo();
5949            let (before, after) = reflow_rows_keep_cursor(ed, row, end_row);
5950            let (new_row, new_col) = reflow_keep_cursor(row, saved.0, saved.1, &before, &after);
5951            buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
5952            ed.push_buffer_cursor_to_textarea();
5953            ed.sticky_col = Some(new_col);
5954            ed.vim.mode = Mode::Normal;
5955        }
5956        Operator::AutoIndent => {
5957            // `==` / `N==` — auto-indent `count` rows starting at cursor.
5958            ed.push_undo();
5959            auto_indent_rows(ed, row, end_row);
5960            ed.sticky_col = Some(ed.cursor().1);
5961            ed.vim.mode = Mode::Normal;
5962        }
5963        Operator::Filter => {
5964            // Filter is dispatched through Editor::filter_range, not here.
5965        }
5966        Operator::Comment => {
5967            // Comment is dispatched through Editor::toggle_comment_range, not here.
5968            // The doubled `gcc` path calls toggle_comment_range directly in
5969            // apply_after_g, then records last_change. execute_line_op should
5970            // not be reached for Comment — no-op if it is.
5971        }
5972    }
5973}
5974
5975// ─── Visual mode operators ─────────────────────────────────────────────────
5976
5977pub(crate) fn apply_visual_operator<H: crate::types::Host>(
5978    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5979    op: Operator,
5980) {
5981    match ed.vim.mode {
5982        Mode::VisualLine => {
5983            let cursor_row = buf_cursor_pos(&ed.buffer).row;
5984            let top = cursor_row.min(ed.vim.visual_line_anchor);
5985            let bot = cursor_row.max(ed.vim.visual_line_anchor);
5986            ed.vim.yank_linewise = true;
5987            match op {
5988                Operator::Yank => {
5989                    let text = read_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
5990                    if !text.is_empty() {
5991                        ed.record_yank_to_host(text.clone());
5992                        ed.record_yank(text, true);
5993                    }
5994                    buf_set_cursor_rc(&mut ed.buffer, top, 0);
5995                    ed.push_buffer_cursor_to_textarea();
5996                    ed.vim.mode = Mode::Normal;
5997                }
5998                Operator::Delete => {
5999                    ed.push_undo();
6000                    cut_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
6001                    ed.vim.mode = Mode::Normal;
6002                }
6003                Operator::Change => {
6004                    // Vim `Vc` / `Vjc`: same linewise-change semantics as
6005                    // `cc` — preserve first line's indent, enter insert.
6006                    change_linewise_rows(ed, top, bot);
6007                }
6008                Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
6009                    let bot = buf_cursor_pos(&ed.buffer)
6010                        .row
6011                        .max(ed.vim.visual_line_anchor);
6012                    apply_case_op_to_selection(ed, op, (top, 0), (bot, 0), RangeKind::Linewise);
6013                    move_first_non_whitespace(ed);
6014                }
6015                Operator::Indent | Operator::Outdent => {
6016                    ed.push_undo();
6017                    let (cursor_row, _) = ed.cursor();
6018                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
6019                    if op == Operator::Indent {
6020                        indent_rows(ed, top, bot, 1);
6021                    } else {
6022                        outdent_rows(ed, top, bot, 1);
6023                    }
6024                    ed.vim.mode = Mode::Normal;
6025                }
6026                Operator::Reflow => {
6027                    ed.push_undo();
6028                    let (cursor_row, _) = ed.cursor();
6029                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
6030                    reflow_rows(ed, top, bot);
6031                    ed.vim.mode = Mode::Normal;
6032                }
6033                Operator::ReflowKeepCursor => {
6034                    let saved = ed.cursor();
6035                    ed.push_undo();
6036                    let (cursor_row, _) = ed.cursor();
6037                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
6038                    let (before, after) = reflow_rows_keep_cursor(ed, top, bot);
6039                    let (new_row, new_col) =
6040                        reflow_keep_cursor(top, saved.0, saved.1, &before, &after);
6041                    buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
6042                    ed.push_buffer_cursor_to_textarea();
6043                    ed.vim.mode = Mode::Normal;
6044                }
6045                Operator::AutoIndent => {
6046                    ed.push_undo();
6047                    let (cursor_row, _) = ed.cursor();
6048                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
6049                    auto_indent_rows(ed, top, bot);
6050                    ed.vim.mode = Mode::Normal;
6051                }
6052                // Filter is dispatched through Editor::filter_range, not here.
6053                Operator::Filter => {}
6054                // Comment is dispatched through the app layer (engine_actions.rs), not here.
6055                Operator::Comment => {}
6056                // Visual `zf` is handled inline in `handle_after_z`,
6057                // never routed through this dispatcher.
6058                Operator::Fold => unreachable!("Visual zf takes its own path"),
6059            }
6060        }
6061        Mode::Visual => {
6062            ed.vim.yank_linewise = false;
6063            let anchor = ed.vim.visual_anchor;
6064            let cursor = ed.cursor();
6065            let (top, bot) = order(anchor, cursor);
6066            match op {
6067                Operator::Yank => {
6068                    let text = read_vim_range(ed, top, bot, RangeKind::Inclusive);
6069                    if !text.is_empty() {
6070                        ed.record_yank_to_host(text.clone());
6071                        ed.record_yank(text, false);
6072                    }
6073                    buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
6074                    ed.push_buffer_cursor_to_textarea();
6075                    ed.vim.mode = Mode::Normal;
6076                }
6077                Operator::Delete => {
6078                    ed.push_undo();
6079                    cut_vim_range(ed, top, bot, RangeKind::Inclusive);
6080                    ed.vim.mode = Mode::Normal;
6081                }
6082                Operator::Change => {
6083                    ed.push_undo();
6084                    cut_vim_range(ed, top, bot, RangeKind::Inclusive);
6085                    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
6086                }
6087                Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
6088                    // Anchor stays where the visual selection started.
6089                    let anchor = ed.vim.visual_anchor;
6090                    let cursor = ed.cursor();
6091                    let (top, bot) = order(anchor, cursor);
6092                    apply_case_op_to_selection(ed, op, top, bot, RangeKind::Inclusive);
6093                }
6094                Operator::Indent | Operator::Outdent => {
6095                    ed.push_undo();
6096                    let anchor = ed.vim.visual_anchor;
6097                    let cursor = ed.cursor();
6098                    let (top, bot) = order(anchor, cursor);
6099                    if op == Operator::Indent {
6100                        indent_rows(ed, top.0, bot.0, 1);
6101                    } else {
6102                        outdent_rows(ed, top.0, bot.0, 1);
6103                    }
6104                    ed.vim.mode = Mode::Normal;
6105                }
6106                Operator::Reflow => {
6107                    ed.push_undo();
6108                    let anchor = ed.vim.visual_anchor;
6109                    let cursor = ed.cursor();
6110                    let (top, bot) = order(anchor, cursor);
6111                    reflow_rows(ed, top.0, bot.0);
6112                    ed.vim.mode = Mode::Normal;
6113                }
6114                Operator::ReflowKeepCursor => {
6115                    let saved = ed.cursor();
6116                    ed.push_undo();
6117                    let anchor = ed.vim.visual_anchor;
6118                    let cursor = ed.cursor();
6119                    let (top, bot) = order(anchor, cursor);
6120                    let (before, after) = reflow_rows_keep_cursor(ed, top.0, bot.0);
6121                    let (new_row, new_col) =
6122                        reflow_keep_cursor(top.0, saved.0, saved.1, &before, &after);
6123                    buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
6124                    ed.push_buffer_cursor_to_textarea();
6125                    ed.vim.mode = Mode::Normal;
6126                }
6127                Operator::AutoIndent => {
6128                    ed.push_undo();
6129                    let anchor = ed.vim.visual_anchor;
6130                    let cursor = ed.cursor();
6131                    let (top, bot) = order(anchor, cursor);
6132                    auto_indent_rows(ed, top.0, bot.0);
6133                    ed.vim.mode = Mode::Normal;
6134                }
6135                // Filter is dispatched through Editor::filter_range, not here.
6136                Operator::Filter => {}
6137                // Comment is dispatched through the app layer (engine_actions.rs), not here.
6138                Operator::Comment => {}
6139                Operator::Fold => unreachable!("Visual zf takes its own path"),
6140            }
6141        }
6142        Mode::VisualBlock => apply_block_operator(ed, op),
6143        _ => {}
6144    }
6145}
6146
6147/// Compute `(top_row, bot_row, left_col, right_col)` for the current
6148/// VisualBlock selection. Columns are inclusive on both ends. Uses the
6149/// tracked virtual column (updated by h/l, preserved across j/k) so
6150/// ragged / empty rows don't collapse the block's width.
6151fn block_bounds<H: crate::types::Host>(
6152    ed: &Editor<hjkl_buffer::Buffer, H>,
6153) -> (usize, usize, usize, usize) {
6154    let (ar, ac) = ed.vim.block_anchor;
6155    let (cr, _) = ed.cursor();
6156    let cc = ed.vim.block_vcol;
6157    let top = ar.min(cr);
6158    let bot = ar.max(cr);
6159    let left = ac.min(cc);
6160    let right = ac.max(cc);
6161    (top, bot, left, right)
6162}
6163
6164/// Update the virtual column after a motion in VisualBlock mode.
6165/// Horizontal motions sync `block_vcol` to the new cursor column;
6166/// vertical / non-h/l motions leave it alone so the intended column
6167/// survives clamping to shorter lines.
6168pub(crate) fn update_block_vcol<H: crate::types::Host>(
6169    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6170    motion: &Motion,
6171) {
6172    match motion {
6173        Motion::Left
6174        | Motion::Right
6175        | Motion::WordFwd
6176        | Motion::BigWordFwd
6177        | Motion::WordBack
6178        | Motion::BigWordBack
6179        | Motion::WordEnd
6180        | Motion::BigWordEnd
6181        | Motion::WordEndBack
6182        | Motion::BigWordEndBack
6183        | Motion::LineStart
6184        | Motion::FirstNonBlank
6185        | Motion::LineEnd
6186        | Motion::Find { .. }
6187        | Motion::FindRepeat { .. }
6188        | Motion::MatchBracket => {
6189            ed.vim.block_vcol = ed.cursor().1;
6190        }
6191        // Up / Down / FileTop / FileBottom / Search — preserve vcol.
6192        _ => {}
6193    }
6194}
6195
6196/// Yank / delete / change / replace a rectangular selection. Yanked text
6197/// is stored as one string per row joined with `\n` so pasting reproduces
6198/// the block as sequential lines. (Vim's true block-paste reinserts as
6199/// columns; we render the content with our char-wise paste path.)
6200fn apply_block_operator<H: crate::types::Host>(
6201    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6202    op: Operator,
6203) {
6204    let (top, bot, left, right) = block_bounds(ed);
6205    // Snapshot the block text for yank / clipboard.
6206    let yank = block_yank(ed, top, bot, left, right);
6207
6208    match op {
6209        Operator::Yank => {
6210            if !yank.is_empty() {
6211                ed.record_yank_to_host(yank.clone());
6212                ed.record_yank(yank, false);
6213            }
6214            ed.vim.mode = Mode::Normal;
6215            ed.jump_cursor(top, left);
6216        }
6217        Operator::Delete => {
6218            ed.push_undo();
6219            delete_block_contents(ed, top, bot, left, right);
6220            if !yank.is_empty() {
6221                ed.record_yank_to_host(yank.clone());
6222                ed.record_delete(yank, false);
6223            }
6224            ed.vim.mode = Mode::Normal;
6225            ed.jump_cursor(top, left);
6226        }
6227        Operator::Change => {
6228            ed.push_undo();
6229            delete_block_contents(ed, top, bot, left, right);
6230            if !yank.is_empty() {
6231                ed.record_yank_to_host(yank.clone());
6232                ed.record_delete(yank, false);
6233            }
6234            ed.jump_cursor(top, left);
6235            begin_insert_noundo(
6236                ed,
6237                1,
6238                InsertReason::BlockChange {
6239                    top,
6240                    bot,
6241                    col: left,
6242                },
6243            );
6244        }
6245        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
6246            ed.push_undo();
6247            transform_block_case(ed, op, top, bot, left, right);
6248            ed.vim.mode = Mode::Normal;
6249            ed.jump_cursor(top, left);
6250        }
6251        Operator::Indent | Operator::Outdent => {
6252            // VisualBlock `>` / `<` falls back to linewise indent over
6253            // the block's row range — vim does the same (column-wise
6254            // indent/outdent doesn't make sense).
6255            ed.push_undo();
6256            if op == Operator::Indent {
6257                indent_rows(ed, top, bot, 1);
6258            } else {
6259                outdent_rows(ed, top, bot, 1);
6260            }
6261            ed.vim.mode = Mode::Normal;
6262        }
6263        Operator::Fold => unreachable!("Visual zf takes its own path"),
6264        Operator::Reflow => {
6265            // Reflow over the block falls back to linewise reflow over
6266            // the row range — column slicing for `gq` doesn't make
6267            // sense.
6268            ed.push_undo();
6269            reflow_rows(ed, top, bot);
6270            ed.vim.mode = Mode::Normal;
6271        }
6272        Operator::ReflowKeepCursor => {
6273            // `gw` over a block: same fallback as `gq` but restore cursor.
6274            let saved = ed.cursor();
6275            ed.push_undo();
6276            let (before, after) = reflow_rows_keep_cursor(ed, top, bot);
6277            let (new_row, new_col) = reflow_keep_cursor(top, saved.0, saved.1, &before, &after);
6278            buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
6279            ed.push_buffer_cursor_to_textarea();
6280            ed.vim.mode = Mode::Normal;
6281        }
6282        Operator::AutoIndent => {
6283            // AutoIndent over the block falls back to linewise
6284            // auto-indent over the row range.
6285            ed.push_undo();
6286            auto_indent_rows(ed, top, bot);
6287            ed.vim.mode = Mode::Normal;
6288        }
6289        // Filter is dispatched through Editor::filter_range, not here.
6290        Operator::Filter => {}
6291        // Comment is dispatched through the app layer (engine_actions.rs), not here.
6292        Operator::Comment => {}
6293    }
6294}
6295
6296/// In-place case transform over the rectangular block
6297/// `(top..=bot, left..=right)`. Rows shorter than `left` are left
6298/// untouched — vim behaves the same way (ragged blocks).
6299fn transform_block_case<H: crate::types::Host>(
6300    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6301    op: Operator,
6302    top: usize,
6303    bot: usize,
6304    left: usize,
6305    right: usize,
6306) {
6307    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
6308    for r in top..=bot.min(lines.len().saturating_sub(1)) {
6309        let chars: Vec<char> = lines[r].chars().collect();
6310        if left >= chars.len() {
6311            continue;
6312        }
6313        let end = (right + 1).min(chars.len());
6314        let head: String = chars[..left].iter().collect();
6315        let mid: String = chars[left..end].iter().collect();
6316        let tail: String = chars[end..].iter().collect();
6317        let transformed = match op {
6318            Operator::Uppercase => mid.to_uppercase(),
6319            Operator::Lowercase => mid.to_lowercase(),
6320            Operator::ToggleCase => toggle_case_str(&mid),
6321            _ => mid,
6322        };
6323        lines[r] = format!("{head}{transformed}{tail}");
6324    }
6325    let saved_yank = ed.yank().to_string();
6326    let saved_linewise = ed.vim.yank_linewise;
6327    ed.restore(lines, (top, left));
6328    ed.set_yank(saved_yank);
6329    ed.vim.yank_linewise = saved_linewise;
6330}
6331
6332fn block_yank<H: crate::types::Host>(
6333    ed: &Editor<hjkl_buffer::Buffer, H>,
6334    top: usize,
6335    bot: usize,
6336    left: usize,
6337    right: usize,
6338) -> String {
6339    let rope = crate::types::Query::rope(&ed.buffer);
6340    let n = rope.len_lines();
6341    let mut rows: Vec<String> = Vec::new();
6342    for r in top..=bot {
6343        if r >= n {
6344            break;
6345        }
6346        let line = rope_line_to_str(&rope, r);
6347        let chars: Vec<char> = line.chars().collect();
6348        let end = (right + 1).min(chars.len());
6349        if left >= chars.len() {
6350            rows.push(String::new());
6351        } else {
6352            rows.push(chars[left..end].iter().collect());
6353        }
6354    }
6355    rows.join("\n")
6356}
6357
6358fn delete_block_contents<H: crate::types::Host>(
6359    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6360    top: usize,
6361    bot: usize,
6362    left: usize,
6363    right: usize,
6364) {
6365    use hjkl_buffer::{Edit, MotionKind, Position};
6366    ed.sync_buffer_content_from_textarea();
6367    let last_row = bot.min(buf_row_count(&ed.buffer).saturating_sub(1));
6368    if last_row < top {
6369        return;
6370    }
6371    ed.mutate_edit(Edit::DeleteRange {
6372        start: Position::new(top, left),
6373        end: Position::new(last_row, right),
6374        kind: MotionKind::Block,
6375    });
6376    ed.push_buffer_cursor_to_textarea();
6377}
6378
6379/// Replace each character cell in the block with `ch`.
6380pub(crate) fn block_replace<H: crate::types::Host>(
6381    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6382    ch: char,
6383) {
6384    let (top, bot, left, right) = block_bounds(ed);
6385    ed.push_undo();
6386    ed.sync_buffer_content_from_textarea();
6387    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
6388    for r in top..=bot.min(lines.len().saturating_sub(1)) {
6389        let chars: Vec<char> = lines[r].chars().collect();
6390        if left >= chars.len() {
6391            continue;
6392        }
6393        let end = (right + 1).min(chars.len());
6394        let before: String = chars[..left].iter().collect();
6395        let middle: String = std::iter::repeat_n(ch, end - left).collect();
6396        let after: String = chars[end..].iter().collect();
6397        lines[r] = format!("{before}{middle}{after}");
6398    }
6399    reset_textarea_lines(ed, lines);
6400    ed.vim.mode = Mode::Normal;
6401    ed.jump_cursor(top, left);
6402}
6403
6404/// Replace buffer content with `lines` while preserving the cursor.
6405/// Used by indent / outdent / block_replace to wholesale rewrite
6406/// rows without going through the per-edit funnel.
6407fn reset_textarea_lines<H: crate::types::Host>(
6408    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6409    lines: Vec<String>,
6410) {
6411    let cursor = ed.cursor();
6412    crate::types::BufferEdit::replace_all(&mut ed.buffer, &lines.join("\n"));
6413    buf_set_cursor_rc(&mut ed.buffer, cursor.0, cursor.1);
6414    ed.mark_content_dirty();
6415}
6416
6417// ─── Visual-line helpers ───────────────────────────────────────────────────
6418
6419// ─── Text-object range computation ─────────────────────────────────────────
6420
6421/// Cursor position as `(row, col)`.
6422type Pos = (usize, usize);
6423
6424/// Returns `(start, end, kind)` where `end` is *exclusive* (one past the
6425/// last character to act on). `kind` is `Linewise` for line-oriented text
6426/// objects like paragraphs and `Exclusive` otherwise.
6427pub(crate) fn text_object_range<H: crate::types::Host>(
6428    ed: &Editor<hjkl_buffer::Buffer, H>,
6429    obj: TextObject,
6430    inner: bool,
6431    count: usize,
6432) -> Option<(Pos, Pos, RangeKind)> {
6433    match obj {
6434        TextObject::Word { big } => {
6435            word_text_object(ed, inner, big).map(|(s, e)| (s, e, RangeKind::Exclusive))
6436        }
6437        TextObject::Quote(q) => {
6438            quote_text_object(ed, q, inner).map(|(s, e)| (s, e, RangeKind::Exclusive))
6439        }
6440        TextObject::Bracket(open) => bracket_text_object(ed, open, inner, count),
6441        TextObject::Paragraph => {
6442            paragraph_text_object(ed, inner).map(|(s, e)| (s, e, RangeKind::Linewise))
6443        }
6444        TextObject::XmlTag => tag_text_object(ed, inner).map(|(s, e)| (s, e, RangeKind::Exclusive)),
6445        TextObject::Sentence => {
6446            sentence_text_object(ed, inner).map(|(s, e)| (s, e, RangeKind::Exclusive))
6447        }
6448    }
6449}
6450
6451/// `(` / `)` — walk to the next sentence boundary in `forward` direction.
6452/// Returns `(row, col)` of the boundary's first non-whitespace cell, or
6453/// `None` when already at the buffer's edge in that direction.
6454fn sentence_boundary<H: crate::types::Host>(
6455    ed: &Editor<hjkl_buffer::Buffer, H>,
6456    forward: bool,
6457) -> Option<(usize, usize)> {
6458    let rope = crate::types::Query::rope(&ed.buffer);
6459    let n_lines = rope.len_lines();
6460    if n_lines == 0 {
6461        return None;
6462    }
6463    // Per-line char counts (excluding trailing \n) for pos↔idx conversion.
6464    let line_lens: Vec<usize> = (0..n_lines)
6465        .map(|r| rope_line_to_str(&rope, r).chars().count())
6466        .collect();
6467    let pos_to_idx = |pos: (usize, usize)| -> usize {
6468        let idx: usize = line_lens.iter().take(pos.0).map(|&len| len + 1).sum();
6469        idx + pos.1
6470    };
6471    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
6472        for (r, &len) in line_lens.iter().enumerate() {
6473            if idx <= len {
6474                return (r, idx);
6475            }
6476            idx -= len + 1;
6477        }
6478        let last = n_lines.saturating_sub(1);
6479        (last, line_lens[last])
6480    };
6481    // Build flat char vector: rope chars already include \n between lines.
6482    // ropey's last line has no trailing \n; intermediate ones do.
6483    let mut chars: Vec<char> = rope.chars().collect();
6484    // Strip a trailing \n if ropey emitted one on the final line.
6485    if chars.last() == Some(&'\n') {
6486        chars.pop();
6487    }
6488    if chars.is_empty() {
6489        return None;
6490    }
6491    let total = chars.len();
6492    let cursor_idx = pos_to_idx(ed.cursor()).min(total - 1);
6493    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
6494
6495    if forward {
6496        // Walk forward looking for a terminator run followed by
6497        // whitespace; land on the first non-whitespace cell after.
6498        let mut i = cursor_idx + 1;
6499        while i < total {
6500            if is_terminator(chars[i]) {
6501                while i + 1 < total && is_terminator(chars[i + 1]) {
6502                    i += 1;
6503                }
6504                if i + 1 >= total {
6505                    return None;
6506                }
6507                if chars[i + 1].is_whitespace() {
6508                    let mut j = i + 1;
6509                    while j < total && chars[j].is_whitespace() {
6510                        j += 1;
6511                    }
6512                    if j >= total {
6513                        return None;
6514                    }
6515                    return Some(idx_to_pos(j));
6516                }
6517            }
6518            i += 1;
6519        }
6520        None
6521    } else {
6522        // Walk backward to find the start of the current sentence (if
6523        // we're already at the start, jump to the previous sentence's
6524        // start instead).
6525        let find_start = |from: usize| -> Option<usize> {
6526            let mut start = from;
6527            while start > 0 {
6528                let prev = chars[start - 1];
6529                if prev.is_whitespace() {
6530                    let mut k = start - 1;
6531                    while k > 0 && chars[k - 1].is_whitespace() {
6532                        k -= 1;
6533                    }
6534                    if k > 0 && is_terminator(chars[k - 1]) {
6535                        break;
6536                    }
6537                }
6538                start -= 1;
6539            }
6540            while start < total && chars[start].is_whitespace() {
6541                start += 1;
6542            }
6543            (start < total).then_some(start)
6544        };
6545        let current_start = find_start(cursor_idx)?;
6546        if current_start < cursor_idx {
6547            return Some(idx_to_pos(current_start));
6548        }
6549        // Already at the sentence start — step over the boundary into
6550        // the previous sentence and find its start.
6551        let mut k = current_start;
6552        while k > 0 && chars[k - 1].is_whitespace() {
6553            k -= 1;
6554        }
6555        if k == 0 {
6556            return None;
6557        }
6558        let prev_start = find_start(k - 1)?;
6559        Some(idx_to_pos(prev_start))
6560    }
6561}
6562
6563/// `is` / `as` — sentence: text up to and including the next sentence
6564/// terminator (`.`, `?`, `!`). Vim treats `.`/`?`/`!` followed by
6565/// whitespace (or end-of-line) as a boundary; runs of consecutive
6566/// terminators stay attached to the same sentence. `as` extends to
6567/// include trailing whitespace; `is` does not.
6568fn sentence_text_object<H: crate::types::Host>(
6569    ed: &Editor<hjkl_buffer::Buffer, H>,
6570    inner: bool,
6571) -> Option<((usize, usize), (usize, usize))> {
6572    let rope = crate::types::Query::rope(&ed.buffer);
6573    let n_lines = rope.len_lines();
6574    if n_lines == 0 {
6575        return None;
6576    }
6577    // Flatten the buffer so a sentence can span lines (vim's behaviour).
6578    // Newlines count as whitespace for boundary detection.
6579    let line_lens: Vec<usize> = (0..n_lines)
6580        .map(|r| rope_line_to_str(&rope, r).chars().count())
6581        .collect();
6582    let pos_to_idx = |pos: (usize, usize)| -> usize {
6583        let idx: usize = line_lens.iter().take(pos.0).map(|&len| len + 1).sum();
6584        idx + pos.1
6585    };
6586    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
6587        for (r, &len) in line_lens.iter().enumerate() {
6588            if idx <= len {
6589                return (r, idx);
6590            }
6591            idx -= len + 1;
6592        }
6593        let last = n_lines.saturating_sub(1);
6594        (last, line_lens[last])
6595    };
6596    let mut chars: Vec<char> = rope.chars().collect();
6597    if chars.last() == Some(&'\n') {
6598        chars.pop();
6599    }
6600    if chars.is_empty() {
6601        return None;
6602    }
6603
6604    let cursor_idx = pos_to_idx(ed.cursor()).min(chars.len() - 1);
6605    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
6606
6607    // Walk backward from cursor to find the start of the current
6608    // sentence. A boundary is: whitespace immediately after a run of
6609    // terminators (or start-of-buffer).
6610    let mut start = cursor_idx;
6611    while start > 0 {
6612        let prev = chars[start - 1];
6613        if prev.is_whitespace() {
6614            // Check if the whitespace follows a terminator — if so,
6615            // we've crossed a sentence boundary; the sentence begins
6616            // at the first non-whitespace cell *after* this run.
6617            let mut k = start - 1;
6618            while k > 0 && chars[k - 1].is_whitespace() {
6619                k -= 1;
6620            }
6621            if k > 0 && is_terminator(chars[k - 1]) {
6622                break;
6623            }
6624        }
6625        start -= 1;
6626    }
6627    // Skip leading whitespace (vim doesn't include it in the
6628    // sentence body).
6629    while start < chars.len() && chars[start].is_whitespace() {
6630        start += 1;
6631    }
6632    if start >= chars.len() {
6633        return None;
6634    }
6635
6636    // Walk forward to the sentence end (last terminator before the
6637    // next whitespace boundary).
6638    let mut end = start;
6639    while end < chars.len() {
6640        if is_terminator(chars[end]) {
6641            // Consume any consecutive terminators (e.g. `?!`).
6642            while end + 1 < chars.len() && is_terminator(chars[end + 1]) {
6643                end += 1;
6644            }
6645            // If followed by whitespace or end-of-buffer, that's the
6646            // boundary.
6647            if end + 1 >= chars.len() || chars[end + 1].is_whitespace() {
6648                break;
6649            }
6650        }
6651        end += 1;
6652    }
6653    // Inclusive end → exclusive end_idx.
6654    let end_idx = (end + 1).min(chars.len());
6655
6656    let final_end = if inner {
6657        end_idx
6658    } else {
6659        // `as`: include trailing whitespace (but stop before the next
6660        // newline so we don't gobble a paragraph break — vim keeps
6661        // sentences within a paragraph for the trailing-ws extension).
6662        let mut e = end_idx;
6663        while e < chars.len() && chars[e].is_whitespace() && chars[e] != '\n' {
6664            e += 1;
6665        }
6666        e
6667    };
6668
6669    Some((idx_to_pos(start), idx_to_pos(final_end)))
6670}
6671
6672/// `it` / `at` — XML tag pair text object. Builds a flat char index of
6673/// the buffer, walks `<...>` tokens to pair tags via a stack, and
6674/// returns the innermost pair containing the cursor.
6675fn tag_text_object<H: crate::types::Host>(
6676    ed: &Editor<hjkl_buffer::Buffer, H>,
6677    inner: bool,
6678) -> Option<((usize, usize), (usize, usize))> {
6679    let rope = crate::types::Query::rope(&ed.buffer);
6680    let n_lines = rope.len_lines();
6681    if n_lines == 0 {
6682        return None;
6683    }
6684    // Flatten char positions so we can compare cursor against tag
6685    // ranges without per-row arithmetic. `\n` between lines counts as
6686    // a single char.
6687    let line_lens: Vec<usize> = (0..n_lines)
6688        .map(|r| rope_line_to_str(&rope, r).chars().count())
6689        .collect();
6690    let pos_to_idx = |pos: (usize, usize)| -> usize {
6691        let idx: usize = line_lens.iter().take(pos.0).map(|&len| len + 1).sum();
6692        idx + pos.1
6693    };
6694    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
6695        for (r, &len) in line_lens.iter().enumerate() {
6696            if idx <= len {
6697                return (r, idx);
6698            }
6699            idx -= len + 1;
6700        }
6701        let last = n_lines.saturating_sub(1);
6702        (last, line_lens[last])
6703    };
6704    let mut chars: Vec<char> = rope.chars().collect();
6705    if chars.last() == Some(&'\n') {
6706        chars.pop();
6707    }
6708    let cursor_idx = pos_to_idx(ed.cursor());
6709
6710    // Walk `<...>` tokens. Track open tags on a stack; on a matching
6711    // close pop and consider the pair a candidate when the cursor lies
6712    // inside its content range. Innermost wins (replace whenever a
6713    // tighter range turns up). Also track the first complete pair that
6714    // starts at or after the cursor so we can fall back to a forward
6715    // scan (targets.vim-style) when the cursor isn't inside any tag.
6716    let mut stack: Vec<(usize, usize, String)> = Vec::new(); // (open_start, content_start, name)
6717    let mut innermost: Option<(usize, usize, usize, usize)> = None;
6718    let mut next_after: Option<(usize, usize, usize, usize)> = None;
6719    let mut i = 0;
6720    while i < chars.len() {
6721        if chars[i] != '<' {
6722            i += 1;
6723            continue;
6724        }
6725        let mut j = i + 1;
6726        while j < chars.len() && chars[j] != '>' {
6727            j += 1;
6728        }
6729        if j >= chars.len() {
6730            break;
6731        }
6732        let inside: String = chars[i + 1..j].iter().collect();
6733        let close_end = j + 1;
6734        let trimmed = inside.trim();
6735        if trimmed.starts_with('!') || trimmed.starts_with('?') {
6736            i = close_end;
6737            continue;
6738        }
6739        if let Some(rest) = trimmed.strip_prefix('/') {
6740            let name = rest.split_whitespace().next().unwrap_or("").to_string();
6741            if !name.is_empty()
6742                && let Some(stack_idx) = stack.iter().rposition(|(_, _, n)| *n == name)
6743            {
6744                let (open_start, content_start, _) = stack[stack_idx].clone();
6745                stack.truncate(stack_idx);
6746                let content_end = i;
6747                let candidate = (open_start, content_start, content_end, close_end);
6748                if cursor_idx >= content_start && cursor_idx <= content_end {
6749                    innermost = match innermost {
6750                        Some((_, cs, ce, _)) if cs <= content_start && content_end <= ce => {
6751                            Some(candidate)
6752                        }
6753                        None => Some(candidate),
6754                        existing => existing,
6755                    };
6756                } else if open_start >= cursor_idx && next_after.is_none() {
6757                    next_after = Some(candidate);
6758                }
6759            }
6760        } else if !trimmed.ends_with('/') {
6761            let name: String = trimmed
6762                .split(|c: char| c.is_whitespace() || c == '/')
6763                .next()
6764                .unwrap_or("")
6765                .to_string();
6766            if !name.is_empty() {
6767                stack.push((i, close_end, name));
6768            }
6769        }
6770        i = close_end;
6771    }
6772
6773    let (open_start, content_start, content_end, close_end) = innermost.or(next_after)?;
6774    if inner {
6775        Some((idx_to_pos(content_start), idx_to_pos(content_end)))
6776    } else {
6777        Some((idx_to_pos(open_start), idx_to_pos(close_end)))
6778    }
6779}
6780
6781fn is_wordchar(c: char) -> bool {
6782    c.is_alphanumeric() || c == '_'
6783}
6784
6785// `is_keyword_char` lives in hjkl-buffer (used by word motions);
6786// engine re-uses it via `hjkl_buffer::is_keyword_char` so there's
6787// one parser, one default, one bug surface.
6788pub(crate) use hjkl_buffer::is_keyword_char;
6789
6790fn word_text_object<H: crate::types::Host>(
6791    ed: &Editor<hjkl_buffer::Buffer, H>,
6792    inner: bool,
6793    big: bool,
6794) -> Option<((usize, usize), (usize, usize))> {
6795    let (row, col) = ed.cursor();
6796    let line = buf_line(&ed.buffer, row)?;
6797    let chars: Vec<char> = line.chars().collect();
6798    if chars.is_empty() {
6799        return None;
6800    }
6801    let at = col.min(chars.len().saturating_sub(1));
6802    let classify = |c: char| -> u8 {
6803        if c.is_whitespace() {
6804            0
6805        } else if big || is_wordchar(c) {
6806            1
6807        } else {
6808            2
6809        }
6810    };
6811    let cls = classify(chars[at]);
6812    let mut start = at;
6813    while start > 0 && classify(chars[start - 1]) == cls {
6814        start -= 1;
6815    }
6816    let mut end = at;
6817    while end + 1 < chars.len() && classify(chars[end + 1]) == cls {
6818        end += 1;
6819    }
6820    // Byte-offset helpers.
6821    let char_byte = |i: usize| {
6822        if i >= chars.len() {
6823            line.len()
6824        } else {
6825            line.char_indices().nth(i).map(|(b, _)| b).unwrap_or(0)
6826        }
6827    };
6828    let mut start_col = char_byte(start);
6829    // Exclusive end: byte index of char AFTER the last-included char.
6830    let mut end_col = char_byte(end + 1);
6831    if !inner {
6832        // `aw` — include trailing whitespace; if there's no trailing ws, absorb leading ws.
6833        let mut t = end + 1;
6834        let mut included_trailing = false;
6835        while t < chars.len() && chars[t].is_whitespace() {
6836            included_trailing = true;
6837            t += 1;
6838        }
6839        if included_trailing {
6840            end_col = char_byte(t);
6841        } else {
6842            let mut s = start;
6843            while s > 0 && chars[s - 1].is_whitespace() {
6844                s -= 1;
6845            }
6846            start_col = char_byte(s);
6847        }
6848    }
6849    Some(((row, start_col), (row, end_col)))
6850}
6851
6852fn quote_text_object<H: crate::types::Host>(
6853    ed: &Editor<hjkl_buffer::Buffer, H>,
6854    q: char,
6855    inner: bool,
6856) -> Option<((usize, usize), (usize, usize))> {
6857    let (row, col) = ed.cursor();
6858    let line = buf_line(&ed.buffer, row)?;
6859    let bytes = line.as_bytes();
6860    let q_byte = q as u8;
6861    // Find opening and closing quote on the same line.
6862    let mut positions: Vec<usize> = Vec::new();
6863    for (i, &b) in bytes.iter().enumerate() {
6864        if b == q_byte {
6865            positions.push(i);
6866        }
6867    }
6868    if positions.len() < 2 {
6869        return None;
6870    }
6871    let mut open_idx: Option<usize> = None;
6872    let mut close_idx: Option<usize> = None;
6873    for pair in positions.chunks(2) {
6874        if pair.len() < 2 {
6875            break;
6876        }
6877        if col >= pair[0] && col <= pair[1] {
6878            open_idx = Some(pair[0]);
6879            close_idx = Some(pair[1]);
6880            break;
6881        }
6882        if col < pair[0] {
6883            open_idx = Some(pair[0]);
6884            close_idx = Some(pair[1]);
6885            break;
6886        }
6887    }
6888    let open = open_idx?;
6889    let close = close_idx?;
6890    // End columns are *exclusive* — one past the last character to act on.
6891    if inner {
6892        if close <= open + 1 {
6893            return None;
6894        }
6895        Some(((row, open + 1), (row, close)))
6896    } else {
6897        // `da<q>` — "around" includes the surrounding whitespace on one
6898        // side: trailing whitespace if any exists after the closing quote;
6899        // otherwise leading whitespace before the opening quote. This
6900        // matches vim's `:help text-objects` behaviour and avoids leaving
6901        // a double-space when the quoted span sits mid-sentence.
6902        let after_close = close + 1; // byte index after closing quote
6903        if after_close < bytes.len() && bytes[after_close].is_ascii_whitespace() {
6904            // Eat trailing whitespace run.
6905            let mut end = after_close;
6906            while end < bytes.len() && bytes[end].is_ascii_whitespace() {
6907                end += 1;
6908            }
6909            Some(((row, open), (row, end)))
6910        } else if open > 0 && bytes[open - 1].is_ascii_whitespace() {
6911            // Eat leading whitespace run.
6912            let mut start = open;
6913            while start > 0 && bytes[start - 1].is_ascii_whitespace() {
6914                start -= 1;
6915            }
6916            Some(((row, start), (row, close + 1)))
6917        } else {
6918            Some(((row, open), (row, close + 1)))
6919        }
6920    }
6921}
6922
6923fn bracket_text_object<H: crate::types::Host>(
6924    ed: &Editor<hjkl_buffer::Buffer, H>,
6925    open: char,
6926    inner: bool,
6927    count: usize,
6928) -> Option<(Pos, Pos, RangeKind)> {
6929    let close = match open {
6930        '(' => ')',
6931        '[' => ']',
6932        '{' => '}',
6933        '<' => '>',
6934        _ => return None,
6935    };
6936    let (row, col) = ed.cursor();
6937    let lines = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
6938    let lines = lines.as_slice();
6939    // If the cursor sits ON the closing bracket, vim anchors the pair to that
6940    // bracket: the close is at the cursor and the open is found by scanning
6941    // backward from just before it. Without this, `find_open_bracket` counts
6942    // the cursor's own close, increments depth, and skips past its matching
6943    // open — making `di}`/`di{`-on-`}` a silent no-op.
6944    let cursor_char = lines.get(row).and_then(|l| l.chars().nth(col));
6945    let (open_pos, close_pos) = if cursor_char == Some(close) {
6946        let open_pos = if col > 0 {
6947            find_open_bracket(lines, row, col - 1, open, close)
6948        } else if row > 0 {
6949            let pr = row - 1;
6950            let pc = lines[pr].chars().count().saturating_sub(1);
6951            find_open_bracket(lines, pr, pc, open, close)
6952        } else {
6953            None
6954        }?;
6955        (open_pos, (row, col))
6956    } else {
6957        // Walk backward from cursor to find unbalanced opening. When the
6958        // cursor isn't inside any pair, fall back to scanning forward for
6959        // the next opening bracket (targets.vim-style: `ci(` works when
6960        // cursor is before the `(` on the same line or below).
6961        let open_pos = find_open_bracket(lines, row, col, open, close)
6962            .or_else(|| find_next_open(lines, row, col, open))?;
6963        let close_pos = find_close_bracket(lines, open_pos.0, open_pos.1 + 1, open, close)?;
6964        (open_pos, close_pos)
6965    };
6966    // Count: `2i{` / `2a{` target the Nth enclosing pair. Expand outward from
6967    // the innermost pair, re-anchoring to each enclosing bracket in turn. Stop
6968    // early (and use the outermost found) if there aren't `count` levels.
6969    let (open_pos, close_pos) = {
6970        let (mut op, mut cp) = (open_pos, close_pos);
6971        for _ in 1..count.max(1) {
6972            let outer = if op.1 > 0 {
6973                find_open_bracket(lines, op.0, op.1 - 1, open, close)
6974            } else if op.0 > 0 {
6975                let pr = op.0 - 1;
6976                let pc = lines[pr].chars().count().saturating_sub(1);
6977                find_open_bracket(lines, pr, pc, open, close)
6978            } else {
6979                None
6980            };
6981            let Some(oo) = outer else { break };
6982            let Some(oc) = find_close_bracket(lines, oo.0, oo.1 + 1, open, close) else {
6983                break;
6984            };
6985            op = oo;
6986            cp = oc;
6987        }
6988        (op, cp)
6989    };
6990    // End positions are *exclusive*.
6991    if inner {
6992        // Multi-line `iB` / `i{` etc: vim deletes the full lines between
6993        // the braces (linewise), preserving the `{` and `}` lines
6994        // themselves and the newlines that directly abut them. E.g.:
6995        //   {\n    body\n}\n  →  {\n}\n    (cursor on `}` line)
6996        // Single-line `i{` falls back to charwise exclusive.
6997        if close_pos.0 > open_pos.0 + 1 {
6998            // There is at least one line strictly between open and close.
6999            let inner_row_start = open_pos.0 + 1;
7000            let inner_row_end = close_pos.0 - 1;
7001            let end_col = lines
7002                .get(inner_row_end)
7003                .map(|l| l.chars().count())
7004                .unwrap_or(0);
7005            return Some((
7006                (inner_row_start, 0),
7007                (inner_row_end, end_col),
7008                RangeKind::Linewise,
7009            ));
7010        }
7011        let inner_start = advance_pos(lines, open_pos);
7012        if inner_start.0 > close_pos.0
7013            || (inner_start.0 == close_pos.0 && inner_start.1 >= close_pos.1)
7014        {
7015            return None;
7016        }
7017        Some((inner_start, close_pos, RangeKind::Exclusive))
7018    } else {
7019        Some((
7020            open_pos,
7021            advance_pos(lines, close_pos),
7022            RangeKind::Exclusive,
7023        ))
7024    }
7025}
7026
7027fn find_open_bracket(
7028    lines: &[String],
7029    row: usize,
7030    col: usize,
7031    open: char,
7032    close: char,
7033) -> Option<(usize, usize)> {
7034    let mut depth: i32 = 0;
7035    let mut r = row;
7036    let mut c = col as isize;
7037    loop {
7038        let cur = &lines[r];
7039        let chars: Vec<char> = cur.chars().collect();
7040        // Clamp `c` to the line length: callers may seed `col` past
7041        // EOL on virtual-cursor lines (e.g., insert mode after `o`)
7042        // so direct indexing would panic on empty / short lines.
7043        if (c as usize) >= chars.len() {
7044            c = chars.len() as isize - 1;
7045        }
7046        while c >= 0 {
7047            let ch = chars[c as usize];
7048            if ch == close {
7049                depth += 1;
7050            } else if ch == open {
7051                if depth == 0 {
7052                    return Some((r, c as usize));
7053                }
7054                depth -= 1;
7055            }
7056            c -= 1;
7057        }
7058        if r == 0 {
7059            return None;
7060        }
7061        r -= 1;
7062        c = lines[r].chars().count() as isize - 1;
7063    }
7064}
7065
7066fn find_close_bracket(
7067    lines: &[String],
7068    row: usize,
7069    start_col: usize,
7070    open: char,
7071    close: char,
7072) -> Option<(usize, usize)> {
7073    let mut depth: i32 = 0;
7074    let mut r = row;
7075    let mut c = start_col;
7076    loop {
7077        let cur = &lines[r];
7078        let chars: Vec<char> = cur.chars().collect();
7079        while c < chars.len() {
7080            let ch = chars[c];
7081            if ch == open {
7082                depth += 1;
7083            } else if ch == close {
7084                if depth == 0 {
7085                    return Some((r, c));
7086                }
7087                depth -= 1;
7088            }
7089            c += 1;
7090        }
7091        if r + 1 >= lines.len() {
7092            return None;
7093        }
7094        r += 1;
7095        c = 0;
7096    }
7097}
7098
7099/// Forward scan from `(row, col)` for the next occurrence of `open`.
7100/// Multi-line. Used by bracket text objects to support targets.vim-style
7101/// "search forward when not currently inside a pair" behaviour.
7102fn find_next_open(lines: &[String], row: usize, col: usize, open: char) -> Option<(usize, usize)> {
7103    let mut r = row;
7104    let mut c = col;
7105    while r < lines.len() {
7106        let chars: Vec<char> = lines[r].chars().collect();
7107        while c < chars.len() {
7108            if chars[c] == open {
7109                return Some((r, c));
7110            }
7111            c += 1;
7112        }
7113        r += 1;
7114        c = 0;
7115    }
7116    None
7117}
7118
7119fn advance_pos(lines: &[String], pos: (usize, usize)) -> (usize, usize) {
7120    let (r, c) = pos;
7121    let line_len = lines[r].chars().count();
7122    if c < line_len {
7123        (r, c + 1)
7124    } else if r + 1 < lines.len() {
7125        (r + 1, 0)
7126    } else {
7127        pos
7128    }
7129}
7130
7131fn paragraph_text_object<H: crate::types::Host>(
7132    ed: &Editor<hjkl_buffer::Buffer, H>,
7133    inner: bool,
7134) -> Option<((usize, usize), (usize, usize))> {
7135    let (row, _) = ed.cursor();
7136    let rope = crate::types::Query::rope(&ed.buffer);
7137    let n_lines = rope.len_lines();
7138    if n_lines == 0 {
7139        return None;
7140    }
7141    // A paragraph is a run of non-blank lines.
7142    let is_blank = |r: usize| -> bool {
7143        if r >= n_lines {
7144            return true;
7145        }
7146        rope_line_to_str(&rope, r).trim().is_empty()
7147    };
7148    if is_blank(row) {
7149        return None;
7150    }
7151    let mut top = row;
7152    while top > 0 && !is_blank(top - 1) {
7153        top -= 1;
7154    }
7155    let mut bot = row;
7156    while bot + 1 < n_lines && !is_blank(bot + 1) {
7157        bot += 1;
7158    }
7159    // For `ap`, include one trailing blank line if present.
7160    if !inner && bot + 1 < n_lines && is_blank(bot + 1) {
7161        bot += 1;
7162    }
7163    let end_col = rope_line_to_str(&rope, bot).chars().count();
7164    Some(((top, 0), (bot, end_col)))
7165}
7166
7167// ─── Individual commands ───────────────────────────────────────────────────
7168
7169/// Read the text in a vim-shaped range without mutating. Used by
7170/// `Operator::Yank` so we can pipe the same range translation as
7171/// [`cut_vim_range`] but skip the delete + inverse extraction.
7172fn read_vim_range<H: crate::types::Host>(
7173    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7174    start: (usize, usize),
7175    end: (usize, usize),
7176    kind: RangeKind,
7177) -> String {
7178    let (top, bot) = order(start, end);
7179    ed.sync_buffer_content_from_textarea();
7180    let rope = crate::types::Query::rope(&ed.buffer);
7181    let n_lines = rope.len_lines();
7182    match kind {
7183        RangeKind::Linewise => {
7184            let lo = top.0;
7185            let hi = bot.0.min(n_lines.saturating_sub(1));
7186            let mut text = rope_row_range_str(&rope, lo, hi);
7187            text.push('\n');
7188            text
7189        }
7190        RangeKind::Inclusive | RangeKind::Exclusive => {
7191            let inclusive = matches!(kind, RangeKind::Inclusive);
7192            // Walk row-by-row collecting chars in `[top, end_exclusive)`.
7193            let mut out = String::new();
7194            for row in top.0..=bot.0 {
7195                if row >= n_lines {
7196                    break;
7197                }
7198                let line = rope_line_to_str(&rope, row);
7199                let lo = if row == top.0 { top.1 } else { 0 };
7200                let hi_unclamped = if row == bot.0 {
7201                    if inclusive { bot.1 + 1 } else { bot.1 }
7202                } else {
7203                    line.chars().count() + 1
7204                };
7205                let row_chars: Vec<char> = line.chars().collect();
7206                let hi = hi_unclamped.min(row_chars.len());
7207                if lo < hi {
7208                    out.push_str(&row_chars[lo..hi].iter().collect::<String>());
7209                }
7210                if row < bot.0 {
7211                    out.push('\n');
7212                }
7213            }
7214            out
7215        }
7216    }
7217}
7218
7219/// Cut a vim-shaped range through the Buffer edit funnel and return
7220/// the deleted text. Translates vim's `RangeKind`
7221/// (Linewise/Inclusive/Exclusive) into the buffer's
7222/// `hjkl_buffer::MotionKind` (Line/Char) and applies the right end-
7223/// position adjustment so inclusive motions actually include the bot
7224/// cell. Pushes the cut text into both `last_yank` and the textarea
7225/// yank buffer (still observed by `p`/`P` until the paste path is
7226/// ported), and updates `yank_linewise` for linewise cuts.
7227fn cut_vim_range<H: crate::types::Host>(
7228    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7229    start: (usize, usize),
7230    end: (usize, usize),
7231    kind: RangeKind,
7232) -> String {
7233    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
7234    let (top, bot) = order(start, end);
7235    ed.sync_buffer_content_from_textarea();
7236    let (buf_start, buf_end, buf_kind) = match kind {
7237        RangeKind::Linewise => (
7238            Position::new(top.0, 0),
7239            Position::new(bot.0, 0),
7240            BufKind::Line,
7241        ),
7242        RangeKind::Inclusive => {
7243            let line_chars = buf_line_chars(&ed.buffer, bot.0);
7244            // Advance one cell past `bot` so the buffer's exclusive
7245            // `cut_chars` actually drops the inclusive endpoint. Wrap
7246            // to the next row when bot already sits on the last char.
7247            let next = if bot.1 < line_chars {
7248                Position::new(bot.0, bot.1 + 1)
7249            } else if bot.0 + 1 < buf_row_count(&ed.buffer) {
7250                Position::new(bot.0 + 1, 0)
7251            } else {
7252                Position::new(bot.0, line_chars)
7253            };
7254            (Position::new(top.0, top.1), next, BufKind::Char)
7255        }
7256        RangeKind::Exclusive => (
7257            Position::new(top.0, top.1),
7258            Position::new(bot.0, bot.1),
7259            BufKind::Char,
7260        ),
7261    };
7262    let inverse = ed.mutate_edit(Edit::DeleteRange {
7263        start: buf_start,
7264        end: buf_end,
7265        kind: buf_kind,
7266    });
7267    let text = match inverse {
7268        Edit::InsertStr { text, .. } => text,
7269        _ => String::new(),
7270    };
7271    if !text.is_empty() {
7272        ed.record_yank_to_host(text.clone());
7273        ed.record_delete(text.clone(), matches!(kind, RangeKind::Linewise));
7274    }
7275    ed.push_buffer_cursor_to_textarea();
7276    text
7277}
7278
7279/// `D` / `C` — delete from cursor to end of line through the edit
7280/// funnel. Mirrors the deleted text into both `ed.last_yank` and the
7281/// textarea's yank buffer (still observed by `p`/`P` until the paste
7282/// path is ported). Cursor lands at the deletion start so the caller
7283/// can decide whether to step it left (`D`) or open insert mode (`C`).
7284fn delete_to_eol<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
7285    use hjkl_buffer::{Edit, MotionKind, Position};
7286    ed.sync_buffer_content_from_textarea();
7287    let cursor = buf_cursor_pos(&ed.buffer);
7288    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
7289    if cursor.col >= line_chars {
7290        return;
7291    }
7292    let inverse = ed.mutate_edit(Edit::DeleteRange {
7293        start: cursor,
7294        end: Position::new(cursor.row, line_chars),
7295        kind: MotionKind::Char,
7296    });
7297    if let Edit::InsertStr { text, .. } = inverse
7298        && !text.is_empty()
7299    {
7300        ed.record_yank_to_host(text.clone());
7301        ed.vim.yank_linewise = false;
7302        ed.set_yank(text);
7303    }
7304    buf_set_cursor_pos(&mut ed.buffer, cursor);
7305    ed.push_buffer_cursor_to_textarea();
7306}
7307
7308fn do_char_delete<H: crate::types::Host>(
7309    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7310    forward: bool,
7311    count: usize,
7312) {
7313    use hjkl_buffer::{Edit, MotionKind, Position};
7314    ed.push_undo();
7315    ed.sync_buffer_content_from_textarea();
7316    // Collect deleted chars so we can write them to the unnamed register
7317    // (vim's `x`/`X` populate `"` so that `xp` round-trips the char).
7318    let mut deleted = String::new();
7319    for _ in 0..count {
7320        let cursor = buf_cursor_pos(&ed.buffer);
7321        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
7322        if forward {
7323            // `x` — delete the char under the cursor. Vim no-ops on
7324            // an empty line; the buffer would drop a row otherwise.
7325            if cursor.col >= line_chars {
7326                continue;
7327            }
7328            let inverse = ed.mutate_edit(Edit::DeleteRange {
7329                start: cursor,
7330                end: Position::new(cursor.row, cursor.col + 1),
7331                kind: MotionKind::Char,
7332            });
7333            if let Edit::InsertStr { text, .. } = inverse {
7334                deleted.push_str(&text);
7335            }
7336        } else {
7337            // `X` — delete the char before the cursor.
7338            if cursor.col == 0 {
7339                continue;
7340            }
7341            let inverse = ed.mutate_edit(Edit::DeleteRange {
7342                start: Position::new(cursor.row, cursor.col - 1),
7343                end: cursor,
7344                kind: MotionKind::Char,
7345            });
7346            if let Edit::InsertStr { text, .. } = inverse {
7347                // X deletes backwards; prepend so the register text
7348                // matches reading order (first deleted char first).
7349                deleted = text + &deleted;
7350            }
7351        }
7352    }
7353    if !deleted.is_empty() {
7354        ed.record_yank_to_host(deleted.clone());
7355        ed.record_delete(deleted, false);
7356    }
7357    ed.push_buffer_cursor_to_textarea();
7358}
7359
7360/// Vim `Ctrl-a` / `Ctrl-x` — find the next decimal number at or after the
7361/// cursor on the current line, add `delta`, leave the cursor on the last
7362/// digit of the result. No-op if the line has no digits to the right.
7363pub(crate) fn adjust_number<H: crate::types::Host>(
7364    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7365    delta: i64,
7366) -> bool {
7367    use hjkl_buffer::{Edit, MotionKind, Position};
7368    ed.sync_buffer_content_from_textarea();
7369    let cursor = buf_cursor_pos(&ed.buffer);
7370    let row = cursor.row;
7371    let chars: Vec<char> = match buf_line(&ed.buffer, row) {
7372        Some(l) => l.chars().collect(),
7373        None => return false,
7374    };
7375    let Some(digit_start) = (cursor.col..chars.len()).find(|&i| chars[i].is_ascii_digit()) else {
7376        return false;
7377    };
7378    let span_start = if digit_start > 0 && chars[digit_start - 1] == '-' {
7379        digit_start - 1
7380    } else {
7381        digit_start
7382    };
7383    let mut span_end = digit_start;
7384    while span_end < chars.len() && chars[span_end].is_ascii_digit() {
7385        span_end += 1;
7386    }
7387    let s: String = chars[span_start..span_end].iter().collect();
7388    let Ok(n) = s.parse::<i64>() else {
7389        return false;
7390    };
7391    let new_s = n.saturating_add(delta).to_string();
7392
7393    ed.push_undo();
7394    let span_start_pos = Position::new(row, span_start);
7395    let span_end_pos = Position::new(row, span_end);
7396    ed.mutate_edit(Edit::DeleteRange {
7397        start: span_start_pos,
7398        end: span_end_pos,
7399        kind: MotionKind::Char,
7400    });
7401    ed.mutate_edit(Edit::InsertStr {
7402        at: span_start_pos,
7403        text: new_s.clone(),
7404    });
7405    let new_len = new_s.chars().count();
7406    buf_set_cursor_rc(&mut ed.buffer, row, span_start + new_len.saturating_sub(1));
7407    ed.push_buffer_cursor_to_textarea();
7408    true
7409}
7410
7411pub(crate) fn replace_char<H: crate::types::Host>(
7412    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7413    ch: char,
7414    count: usize,
7415) {
7416    use hjkl_buffer::{Edit, MotionKind, Position};
7417    ed.push_undo();
7418    ed.sync_buffer_content_from_textarea();
7419    for _ in 0..count {
7420        let cursor = buf_cursor_pos(&ed.buffer);
7421        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
7422        if cursor.col >= line_chars {
7423            break;
7424        }
7425        ed.mutate_edit(Edit::DeleteRange {
7426            start: cursor,
7427            end: Position::new(cursor.row, cursor.col + 1),
7428            kind: MotionKind::Char,
7429        });
7430        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
7431    }
7432    // Vim leaves the cursor on the last replaced char.
7433    crate::motions::move_left(&mut ed.buffer, 1);
7434    ed.push_buffer_cursor_to_textarea();
7435}
7436
7437fn toggle_case_at_cursor<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
7438    use hjkl_buffer::{Edit, MotionKind, Position};
7439    ed.sync_buffer_content_from_textarea();
7440    let cursor = buf_cursor_pos(&ed.buffer);
7441    let Some(c) = buf_line(&ed.buffer, cursor.row).and_then(|l| l.chars().nth(cursor.col)) else {
7442        return;
7443    };
7444    let toggled = if c.is_uppercase() {
7445        c.to_lowercase().next().unwrap_or(c)
7446    } else {
7447        c.to_uppercase().next().unwrap_or(c)
7448    };
7449    ed.mutate_edit(Edit::DeleteRange {
7450        start: cursor,
7451        end: Position::new(cursor.row, cursor.col + 1),
7452        kind: MotionKind::Char,
7453    });
7454    ed.mutate_edit(Edit::InsertChar {
7455        at: cursor,
7456        ch: toggled,
7457    });
7458}
7459
7460fn join_line<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
7461    use hjkl_buffer::{Edit, Position};
7462    ed.sync_buffer_content_from_textarea();
7463    let row = buf_cursor_pos(&ed.buffer).row;
7464    if row + 1 >= buf_row_count(&ed.buffer) {
7465        return;
7466    }
7467    let cur_line = buf_line(&ed.buffer, row).unwrap_or_default();
7468    let next_raw = buf_line(&ed.buffer, row + 1).unwrap_or_default();
7469    let next_trimmed = next_raw.trim_start();
7470    let cur_chars = cur_line.chars().count();
7471    let next_chars = next_raw.chars().count();
7472    // `J` inserts a single space iff both sides are non-empty after
7473    // stripping the next line's leading whitespace.
7474    let separator = if !cur_line.is_empty() && !next_trimmed.is_empty() {
7475        " "
7476    } else {
7477        ""
7478    };
7479    let joined = format!("{cur_line}{separator}{next_trimmed}");
7480    ed.mutate_edit(Edit::Replace {
7481        start: Position::new(row, 0),
7482        end: Position::new(row + 1, next_chars),
7483        with: joined,
7484    });
7485    // Vim parks the cursor on the inserted space — or at the join
7486    // point when no space went in (which is the same column either
7487    // way, since the space sits exactly at `cur_chars`).
7488    buf_set_cursor_rc(&mut ed.buffer, row, cur_chars);
7489    ed.push_buffer_cursor_to_textarea();
7490}
7491
7492/// `gJ` — join the next line onto the current one without inserting a
7493/// separating space or stripping leading whitespace.
7494fn join_line_raw<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
7495    use hjkl_buffer::Edit;
7496    ed.sync_buffer_content_from_textarea();
7497    let row = buf_cursor_pos(&ed.buffer).row;
7498    if row + 1 >= buf_row_count(&ed.buffer) {
7499        return;
7500    }
7501    let join_col = buf_line_chars(&ed.buffer, row);
7502    ed.mutate_edit(Edit::JoinLines {
7503        row,
7504        count: 1,
7505        with_space: false,
7506    });
7507    // Vim leaves the cursor at the join point (end of original line).
7508    buf_set_cursor_rc(&mut ed.buffer, row, join_col);
7509    ed.push_buffer_cursor_to_textarea();
7510}
7511
7512fn do_paste<H: crate::types::Host>(
7513    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7514    before: bool,
7515    count: usize,
7516) {
7517    use hjkl_buffer::{Edit, Position};
7518    ed.push_undo();
7519    // Resolve the source register: `"reg` prefix (consumed) or the
7520    // unnamed register otherwise. Read text + linewise from the
7521    // selected slot rather than the global `vim.yank_linewise` so
7522    // pasting from `"0` after a delete still uses the yank's layout.
7523    let selector = ed.vim.pending_register.take();
7524    let (yank, linewise) = match selector.and_then(|c| ed.registers().read(c)) {
7525        Some(slot) => (slot.text.clone(), slot.linewise),
7526        // Read both fields from the unnamed slot rather than mixing the
7527        // slot's text with `vim.yank_linewise`. The cached vim flag is
7528        // per-editor, so a register imported from another editor (e.g.
7529        // cross-buffer yank/paste) carried the wrong linewise without
7530        // this — pasting a linewise yank inserted at the char cursor.
7531        None => {
7532            let s = &ed.registers().unnamed;
7533            (s.text.clone(), s.linewise)
7534        }
7535    };
7536    // Vim `:h '[` / `:h ']`: after paste `[` = first inserted char of
7537    // the final paste, `]` = last inserted char of the final paste.
7538    // We track (lo, hi) across iterations; the last value wins.
7539    let mut paste_mark: Option<((usize, usize), (usize, usize))> = None;
7540    // Capture the cursor row before any paste iterations. Vim's
7541    // linewise `[count]p` lands the cursor on the FIRST pasted line
7542    // (original_row + 1), not on the last iteration's paste row.
7543    // Without this snapshot the per-iteration cursor advancement leaves
7544    // the cursor at `original_row + count` instead.
7545    let original_row_for_linewise_after = if linewise && !before {
7546        Some(buf_cursor_pos(&ed.buffer).row)
7547    } else {
7548        None
7549    };
7550    for _ in 0..count {
7551        ed.sync_buffer_content_from_textarea();
7552        let yank = yank.clone();
7553        if yank.is_empty() {
7554            continue;
7555        }
7556        if linewise {
7557            // Linewise paste: insert payload as fresh row(s) above
7558            // (`P`) or below (`p`) the cursor's row. Cursor lands on
7559            // the first non-blank of the first pasted line.
7560            let text = yank.trim_matches('\n').to_string();
7561            let row = buf_cursor_pos(&ed.buffer).row;
7562            let target_row = if before {
7563                ed.mutate_edit(Edit::InsertStr {
7564                    at: Position::new(row, 0),
7565                    text: format!("{text}\n"),
7566                });
7567                row
7568            } else {
7569                let line_chars = buf_line_chars(&ed.buffer, row);
7570                ed.mutate_edit(Edit::InsertStr {
7571                    at: Position::new(row, line_chars),
7572                    text: format!("\n{text}"),
7573                });
7574                row + 1
7575            };
7576            buf_set_cursor_rc(&mut ed.buffer, target_row, 0);
7577            crate::motions::move_first_non_blank(&mut ed.buffer);
7578            ed.push_buffer_cursor_to_textarea();
7579            // Linewise: `[` = (target_row, 0), `]` = (bot_row, last_col).
7580            let payload_lines = text.lines().count().max(1);
7581            let bot_row = target_row + payload_lines - 1;
7582            let bot_last_col = buf_line_chars(&ed.buffer, bot_row).saturating_sub(1);
7583            paste_mark = Some(((target_row, 0), (bot_row, bot_last_col)));
7584        } else {
7585            // Charwise paste. `P` inserts at cursor (shifting cell
7586            // right); `p` inserts after cursor (advance one cell
7587            // first, clamped to the end of the line).
7588            let cursor = buf_cursor_pos(&ed.buffer);
7589            let at = if before {
7590                cursor
7591            } else {
7592                let line_chars = buf_line_chars(&ed.buffer, cursor.row);
7593                Position::new(cursor.row, (cursor.col + 1).min(line_chars))
7594            };
7595            ed.mutate_edit(Edit::InsertStr {
7596                at,
7597                text: yank.clone(),
7598            });
7599            // Vim parks the cursor on the last char of the pasted
7600            // text (do_insert_str leaves it one past the end).
7601            crate::motions::move_left(&mut ed.buffer, 1);
7602            ed.push_buffer_cursor_to_textarea();
7603            // Charwise: `[` = insert start, `]` = cursor (last pasted char).
7604            let lo = (at.row, at.col);
7605            let hi = ed.cursor();
7606            paste_mark = Some((lo, hi));
7607        }
7608    }
7609    if let Some((lo, hi)) = paste_mark {
7610        ed.set_mark('[', lo);
7611        ed.set_mark(']', hi);
7612    }
7613    // Linewise `p` (after) with count: cursor lands on the FIRST pasted
7614    // line (original_row + 1) — vim parity. The per-iteration loop
7615    // moves cursor to each paste's target_row, so without this reset
7616    // `5p` would land at original_row + 5 instead of original_row + 1.
7617    if let Some(orig_row) = original_row_for_linewise_after {
7618        let first_target = orig_row.saturating_add(1);
7619        buf_set_cursor_rc(&mut ed.buffer, first_target, 0);
7620        crate::motions::move_first_non_blank(&mut ed.buffer);
7621        ed.push_buffer_cursor_to_textarea();
7622    }
7623    // Any paste re-anchors the sticky column to the new cursor position.
7624    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
7625}
7626
7627pub(crate) fn do_undo<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
7628    if let Some(entry) = ed.undo_stack.pop() {
7629        let (cur_rope, cur_cursor) = ed.snapshot();
7630        ed.redo_stack.push(crate::editor::UndoEntry {
7631            rope: cur_rope,
7632            cursor: cur_cursor,
7633            timestamp: entry.timestamp,
7634        });
7635        ed.restore_rope(entry.rope, entry.cursor);
7636    }
7637    ed.vim.mode = Mode::Normal;
7638    // The restored cursor came from a snapshot taken in insert mode
7639    // (before the insert started) and may be past the last valid
7640    // normal-mode column. Clamp it now, same as Esc-from-insert does.
7641    clamp_cursor_to_normal_mode(ed);
7642}
7643
7644pub(crate) fn do_redo<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
7645    if let Some(entry) = ed.redo_stack.pop() {
7646        let (cur_rope, cur_cursor) = ed.snapshot();
7647        ed.undo_stack.push(crate::editor::UndoEntry {
7648            rope: cur_rope,
7649            cursor: cur_cursor,
7650            timestamp: entry.timestamp,
7651        });
7652        ed.cap_undo();
7653        ed.restore_rope(entry.rope, entry.cursor);
7654    }
7655    ed.vim.mode = Mode::Normal;
7656}
7657
7658// ─── Dot repeat ────────────────────────────────────────────────────────────
7659
7660/// Replay-side helper: insert `text` at the cursor through the
7661/// edit funnel, then leave insert mode (the original change ended
7662/// with Esc, so the dot-repeat must end the same way — including
7663/// the cursor step-back vim does on Esc-from-insert).
7664fn replay_insert_and_finish<H: crate::types::Host>(
7665    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7666    text: &str,
7667) {
7668    use hjkl_buffer::{Edit, Position};
7669    let cursor = ed.cursor();
7670    ed.mutate_edit(Edit::InsertStr {
7671        at: Position::new(cursor.0, cursor.1),
7672        text: text.to_string(),
7673    });
7674    if ed.vim.insert_session.take().is_some() {
7675        if ed.cursor().1 > 0 {
7676            crate::motions::move_left(&mut ed.buffer, 1);
7677            ed.push_buffer_cursor_to_textarea();
7678        }
7679        ed.vim.mode = Mode::Normal;
7680    }
7681}
7682
7683pub(crate) fn replay_last_change<H: crate::types::Host>(
7684    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7685    outer_count: usize,
7686) {
7687    let Some(change) = ed.vim.last_change.clone() else {
7688        return;
7689    };
7690    ed.vim.replaying = true;
7691    let scale = if outer_count > 0 { outer_count } else { 1 };
7692    match change {
7693        LastChange::OpMotion {
7694            op,
7695            motion,
7696            count,
7697            inserted,
7698        } => {
7699            let total = count.max(1) * scale;
7700            apply_op_with_motion(ed, op, &motion, total);
7701            if let Some(text) = inserted {
7702                replay_insert_and_finish(ed, &text);
7703            }
7704        }
7705        LastChange::OpTextObj {
7706            op,
7707            obj,
7708            inner,
7709            inserted,
7710        } => {
7711            // Dot-repeat replays the text object at count 1 (the original
7712            // count is not retained in `LastChange::OpTextObj`).
7713            apply_op_with_text_object(ed, op, obj, inner, 1);
7714            if let Some(text) = inserted {
7715                replay_insert_and_finish(ed, &text);
7716            }
7717        }
7718        LastChange::LineOp {
7719            op,
7720            count,
7721            inserted,
7722        } => {
7723            let total = count.max(1) * scale;
7724            execute_line_op(ed, op, total);
7725            if let Some(text) = inserted {
7726                replay_insert_and_finish(ed, &text);
7727            }
7728        }
7729        LastChange::CharDel { forward, count } => {
7730            do_char_delete(ed, forward, count * scale);
7731        }
7732        LastChange::ReplaceChar { ch, count } => {
7733            replace_char(ed, ch, count * scale);
7734        }
7735        LastChange::ToggleCase { count } => {
7736            for _ in 0..count * scale {
7737                ed.push_undo();
7738                toggle_case_at_cursor(ed);
7739            }
7740        }
7741        LastChange::JoinLine { count } => {
7742            for _ in 0..count * scale {
7743                ed.push_undo();
7744                join_line(ed);
7745            }
7746        }
7747        LastChange::Paste { before, count } => {
7748            do_paste(ed, before, count * scale);
7749        }
7750        LastChange::DeleteToEol { inserted } => {
7751            use hjkl_buffer::{Edit, Position};
7752            ed.push_undo();
7753            delete_to_eol(ed);
7754            if let Some(text) = inserted {
7755                let cursor = ed.cursor();
7756                ed.mutate_edit(Edit::InsertStr {
7757                    at: Position::new(cursor.0, cursor.1),
7758                    text,
7759                });
7760            }
7761        }
7762        LastChange::OpenLine { above, inserted } => {
7763            use hjkl_buffer::{Edit, Position};
7764            ed.push_undo();
7765            ed.sync_buffer_content_from_textarea();
7766            let row = buf_cursor_pos(&ed.buffer).row;
7767            if above {
7768                ed.mutate_edit(Edit::InsertStr {
7769                    at: Position::new(row, 0),
7770                    text: "\n".to_string(),
7771                });
7772                let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
7773                crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
7774            } else {
7775                let line_chars = buf_line_chars(&ed.buffer, row);
7776                ed.mutate_edit(Edit::InsertStr {
7777                    at: Position::new(row, line_chars),
7778                    text: "\n".to_string(),
7779                });
7780            }
7781            ed.push_buffer_cursor_to_textarea();
7782            let cursor = ed.cursor();
7783            ed.mutate_edit(Edit::InsertStr {
7784                at: Position::new(cursor.0, cursor.1),
7785                text: inserted,
7786            });
7787        }
7788        LastChange::InsertAt {
7789            entry,
7790            inserted,
7791            count,
7792        } => {
7793            use hjkl_buffer::{Edit, Position};
7794            ed.push_undo();
7795            match entry {
7796                InsertEntry::I => {}
7797                InsertEntry::ShiftI => move_first_non_whitespace(ed),
7798                InsertEntry::A => {
7799                    crate::motions::move_right_to_end(&mut ed.buffer, 1);
7800                    ed.push_buffer_cursor_to_textarea();
7801                }
7802                InsertEntry::ShiftA => {
7803                    crate::motions::move_line_end(&mut ed.buffer);
7804                    crate::motions::move_right_to_end(&mut ed.buffer, 1);
7805                    ed.push_buffer_cursor_to_textarea();
7806                }
7807            }
7808            for _ in 0..count.max(1) {
7809                let cursor = ed.cursor();
7810                ed.mutate_edit(Edit::InsertStr {
7811                    at: Position::new(cursor.0, cursor.1),
7812                    text: inserted.clone(),
7813                });
7814            }
7815        }
7816    }
7817    ed.vim.replaying = false;
7818}
7819
7820// ─── Extracting inserted text for replay ───────────────────────────────────
7821
7822fn extract_inserted(before: &str, after: &str) -> String {
7823    let before_chars: Vec<char> = before.chars().collect();
7824    let after_chars: Vec<char> = after.chars().collect();
7825    if after_chars.len() <= before_chars.len() {
7826        return String::new();
7827    }
7828    let prefix = before_chars
7829        .iter()
7830        .zip(after_chars.iter())
7831        .take_while(|(a, b)| a == b)
7832        .count();
7833    let max_suffix = before_chars.len() - prefix;
7834    let suffix = before_chars
7835        .iter()
7836        .rev()
7837        .zip(after_chars.iter().rev())
7838        .take(max_suffix)
7839        .take_while(|(a, b)| a == b)
7840        .count();
7841    after_chars[prefix..after_chars.len() - suffix]
7842        .iter()
7843        .collect()
7844}
7845
7846// ─── Tests ────────────────────────────────────────────────────────────────
7847
7848#[cfg(test)]
7849mod comment_continuation_tests {
7850    use super::*;
7851    use crate::{DefaultHost, Editor, Options};
7852    use hjkl_buffer::Buffer;
7853
7854    fn make_editor_with_lang(lang: &str, content: &str) -> Editor<Buffer, DefaultHost> {
7855        let buf = Buffer::from_str(content);
7856        let host = DefaultHost::new();
7857        let opts = Options {
7858            filetype: lang.to_string(),
7859            formatoptions: "ro".to_string(),
7860            ..Options::default()
7861        };
7862        Editor::new(buf, host, opts)
7863    }
7864
7865    #[test]
7866    fn detect_rust_doc_comment() {
7867        let result = detect_comment_on_line("rust", "/// foo bar");
7868        assert!(result.is_some());
7869        let (indent, prefix) = result.unwrap();
7870        assert_eq!(indent, "");
7871        assert_eq!(prefix, "/// ");
7872    }
7873
7874    #[test]
7875    fn detect_rust_inner_doc_comment() {
7876        let result = detect_comment_on_line("rust", "//! crate docs");
7877        assert!(result.is_some());
7878        let (_, prefix) = result.unwrap();
7879        assert_eq!(prefix, "//! ");
7880    }
7881
7882    #[test]
7883    fn detect_rust_plain_comment() {
7884        let result = detect_comment_on_line("rust", "// normal comment");
7885        assert!(result.is_some());
7886        let (_, prefix) = result.unwrap();
7887        assert_eq!(prefix, "// ");
7888    }
7889
7890    #[test]
7891    fn detect_indented_comment() {
7892        let result = detect_comment_on_line("rust", "    // indented");
7893        assert!(result.is_some());
7894        let (indent, prefix) = result.unwrap();
7895        assert_eq!(indent, "    ");
7896        assert_eq!(prefix, "// ");
7897    }
7898
7899    #[test]
7900    fn detect_python_hash() {
7901        let result = detect_comment_on_line("python", "# comment");
7902        assert!(result.is_some());
7903        let (_, prefix) = result.unwrap();
7904        assert_eq!(prefix, "# ");
7905    }
7906
7907    #[test]
7908    fn detect_lua_double_dash() {
7909        let result = detect_comment_on_line("lua", "-- a lua comment");
7910        assert!(result.is_some());
7911        let (_, prefix) = result.unwrap();
7912        assert_eq!(prefix, "-- ");
7913    }
7914
7915    #[test]
7916    fn detect_non_comment_is_none() {
7917        assert!(detect_comment_on_line("rust", "let x = 1;").is_none());
7918        assert!(detect_comment_on_line("python", "x = 1").is_none());
7919    }
7920
7921    #[test]
7922    fn detect_bare_double_slash_still_matches() {
7923        // A line that is exactly `//` with nothing after.
7924        assert!(detect_comment_on_line("rust", "//").is_some());
7925    }
7926
7927    #[test]
7928    fn rust_doc_before_plain() {
7929        // `///` must match before `//`.
7930        let result = detect_comment_on_line("rust", "/// outer doc");
7931        let (_, prefix) = result.unwrap();
7932        assert_eq!(prefix, "/// ", "/// must match before //");
7933    }
7934
7935    #[test]
7936    fn continue_comment_returns_prefix_for_comment_row() {
7937        let ed = make_editor_with_lang("rust", "/// hello\n");
7938        let cont = continue_comment(&ed.buffer, &ed.settings, 0);
7939        assert_eq!(cont, Some("/// ".to_string()));
7940    }
7941
7942    #[test]
7943    fn continue_comment_returns_none_for_non_comment() {
7944        let ed = make_editor_with_lang("rust", "let x = 1;\n");
7945        let cont = continue_comment(&ed.buffer, &ed.settings, 0);
7946        assert!(cont.is_none());
7947    }
7948
7949    #[test]
7950    fn continue_comment_returns_none_when_filetype_empty() {
7951        let buf = Buffer::from_str("// hello\n");
7952        let host = DefaultHost::new();
7953        // filetype defaults to "" in Options::default().
7954        let ed = Editor::new(buf, host, Options::default());
7955        let cont = continue_comment(&ed.buffer, &ed.settings, 0);
7956        assert!(cont.is_none());
7957    }
7958}
7959
7960#[cfg(test)]
7961mod comment_toggle_tests {
7962    use super::*;
7963    use crate::{DefaultHost, Editor, Options};
7964    use hjkl_buffer::Buffer;
7965
7966    fn make_rust_editor(content: &str) -> Editor<Buffer, DefaultHost> {
7967        let buf = Buffer::from_str(content);
7968        let host = DefaultHost::new();
7969        let opts = Options {
7970            filetype: "rust".to_string(),
7971            ..Options::default()
7972        };
7973        Editor::new(buf, host, opts)
7974    }
7975
7976    fn line(ed: &Editor<Buffer, DefaultHost>, row: usize) -> String {
7977        buf_line(&ed.buffer, row).unwrap_or_default()
7978    }
7979
7980    // ── gcc: toggle comment on current line ──────────────────────────────────
7981
7982    #[test]
7983    fn gcc_comments_rust_line() {
7984        let mut ed = make_rust_editor("let x = 1;");
7985        ed.toggle_comment_range(0, 0);
7986        assert_eq!(line(&ed, 0), "// let x = 1;");
7987    }
7988
7989    #[test]
7990    fn gcc_uncomments_rust_line() {
7991        let mut ed = make_rust_editor("// let x = 1;");
7992        ed.toggle_comment_range(0, 0);
7993        assert_eq!(line(&ed, 0), "let x = 1;");
7994    }
7995
7996    #[test]
7997    fn gcc_indent_preserving() {
7998        // Marker inserted after leading whitespace, not at column 0.
7999        let mut ed = make_rust_editor("    let x = 1;");
8000        ed.toggle_comment_range(0, 0);
8001        assert_eq!(line(&ed, 0), "    // let x = 1;");
8002    }
8003
8004    #[test]
8005    fn gcc_indent_preserving_uncomment() {
8006        let mut ed = make_rust_editor("    // let x = 1;");
8007        ed.toggle_comment_range(0, 0);
8008        assert_eq!(line(&ed, 0), "    let x = 1;");
8009    }
8010
8011    // ── Multi-line toggle ────────────────────────────────────────────────────
8012
8013    #[test]
8014    fn toggle_multi_line_all_uncommented() {
8015        let content = "let a = 1;\nlet b = 2;\nlet c = 3;";
8016        let mut ed = make_rust_editor(content);
8017        ed.toggle_comment_range(0, 2);
8018        assert_eq!(line(&ed, 0), "// let a = 1;");
8019        assert_eq!(line(&ed, 1), "// let b = 2;");
8020        assert_eq!(line(&ed, 2), "// let c = 3;");
8021    }
8022
8023    #[test]
8024    fn toggle_multi_line_all_commented() {
8025        let content = "// let a = 1;\n// let b = 2;\n// let c = 3;";
8026        let mut ed = make_rust_editor(content);
8027        ed.toggle_comment_range(0, 2);
8028        assert_eq!(line(&ed, 0), "let a = 1;");
8029        assert_eq!(line(&ed, 1), "let b = 2;");
8030        assert_eq!(line(&ed, 2), "let c = 3;");
8031    }
8032
8033    // ── Mixed state → all gets commented (vim-commentary parity) ────────────
8034
8035    #[test]
8036    fn toggle_mixed_state_comments_all() {
8037        // 3 uncommented + 2 commented → all 5 get commented.
8038        let content = "let a = 1;\n// let b = 2;\nlet c = 3;\n// let d = 4;\nlet e = 5;";
8039        let mut ed = make_rust_editor(content);
8040        ed.toggle_comment_range(0, 4);
8041        for r in 0..5 {
8042            assert!(
8043                line(&ed, r).trim_start().starts_with("//"),
8044                "row {r} not commented: {:?}",
8045                line(&ed, r)
8046            );
8047        }
8048    }
8049
8050    // ── Blank lines skipped ──────────────────────────────────────────────────
8051
8052    #[test]
8053    fn blank_lines_not_commented() {
8054        let content = "let a = 1;\n\nlet b = 2;";
8055        let mut ed = make_rust_editor(content);
8056        ed.toggle_comment_range(0, 2);
8057        assert_eq!(line(&ed, 0), "// let a = 1;");
8058        assert_eq!(line(&ed, 1), ""); // blank — untouched
8059        assert_eq!(line(&ed, 2), "// let b = 2;");
8060    }
8061
8062    // ── Python hash comments ─────────────────────────────────────────────────
8063
8064    #[test]
8065    fn python_comment_toggle() {
8066        let buf = Buffer::from_str("x = 1\ny = 2");
8067        let host = DefaultHost::new();
8068        let opts = Options {
8069            filetype: "python".to_string(),
8070            ..Options::default()
8071        };
8072        let mut ed = Editor::new(buf, host, opts);
8073        ed.toggle_comment_range(0, 1);
8074        assert_eq!(line(&ed, 0), "# x = 1");
8075        assert_eq!(line(&ed, 1), "# y = 2");
8076        // Toggle back.
8077        ed.toggle_comment_range(0, 1);
8078        assert_eq!(line(&ed, 0), "x = 1");
8079        assert_eq!(line(&ed, 1), "y = 2");
8080    }
8081
8082    // ── commentstring override ───────────────────────────────────────────────
8083
8084    #[test]
8085    fn commentstring_override_via_setting() {
8086        let buf = Buffer::from_str("hello world");
8087        let host = DefaultHost::new();
8088        let opts = Options {
8089            filetype: "rust".to_string(),
8090            ..Options::default()
8091        };
8092        let mut ed = Editor::new(buf, host, opts);
8093        // Override with a custom marker.
8094        ed.settings_mut().commentstring = "# %s".to_string();
8095        ed.toggle_comment_range(0, 0);
8096        assert_eq!(line(&ed, 0), "# hello world");
8097    }
8098
8099    // ── Unknown language → no-op ─────────────────────────────────────────────
8100
8101    #[test]
8102    fn unknown_lang_no_op() {
8103        let buf = Buffer::from_str("hello");
8104        let host = DefaultHost::new();
8105        let opts = Options::default(); // filetype = ""
8106        let mut ed = Editor::new(buf, host, opts);
8107        ed.toggle_comment_range(0, 0);
8108        // Should be unchanged — no comment string for "".
8109        assert_eq!(line(&ed, 0), "hello");
8110    }
8111}
8112
8113// ─── g& tests ─────────────────────────────────────────────────────────────
8114
8115#[cfg(test)]
8116mod g_ampersand_tests {
8117    use super::*;
8118    use crate::{DefaultHost, Editor, Options};
8119    use hjkl_buffer::{Buffer, rope_line_str};
8120
8121    fn make_editor(content: &str) -> Editor<Buffer, DefaultHost> {
8122        let buf = Buffer::from_str(content);
8123        let host = DefaultHost::new();
8124        Editor::new(buf, host, Options::default())
8125    }
8126
8127    fn buf_line(ed: &Editor<Buffer, DefaultHost>, row: usize) -> String {
8128        let rope = ed.buffer().rope();
8129        rope_line_str(&rope, row).trim_end_matches('\n').to_string()
8130    }
8131
8132    /// `g&` repeats last `:s/foo/bar/` over every line (no /g flag → first
8133    /// match per line only).
8134    #[test]
8135    fn g_ampersand_repeats_last_substitute_on_whole_buffer() {
8136        let mut ed = make_editor("foo\nfoo bar foo\nbaz");
8137        // Simulate a prior `:s/foo/bar/` by setting last_substitute directly.
8138        let cmd = crate::substitute::parse_substitute("/foo/bar/").unwrap();
8139        ed.set_last_substitute(cmd);
8140        // Cursor on line 0 (to confirm g& operates on ALL lines, not just current).
8141        apply_after_g(&mut ed, '&', 1);
8142        assert_eq!(buf_line(&ed, 0), "bar");
8143        // No /g flag — only first match per line.
8144        assert_eq!(buf_line(&ed, 1), "bar bar foo");
8145        assert_eq!(buf_line(&ed, 2), "baz");
8146    }
8147
8148    /// `g&` with /g flag replaces all matches per line.
8149    #[test]
8150    fn g_ampersand_with_g_flag_replaces_all_per_line() {
8151        let mut ed = make_editor("foo foo\nfoo");
8152        let cmd = crate::substitute::parse_substitute("/foo/bar/g").unwrap();
8153        ed.set_last_substitute(cmd);
8154        apply_after_g(&mut ed, '&', 1);
8155        assert_eq!(buf_line(&ed, 0), "bar bar");
8156        assert_eq!(buf_line(&ed, 1), "bar");
8157    }
8158
8159    /// `g&` with no prior substitute is a no-op.
8160    #[test]
8161    fn g_ampersand_noop_when_no_prior_substitute() {
8162        let mut ed = make_editor("foo\nbar");
8163        // No last_substitute set — must not panic, must not change buffer.
8164        apply_after_g(&mut ed, '&', 1);
8165        assert_eq!(buf_line(&ed, 0), "foo");
8166        assert_eq!(buf_line(&ed, 1), "bar");
8167    }
8168}
8169
8170// ─── Sneak motion tests ───────────────────────────────────────────────────
8171
8172#[cfg(test)]
8173mod sneak_tests {
8174    use super::*;
8175    use crate::{DefaultHost, Editor, Options};
8176    use hjkl_buffer::Buffer;
8177
8178    fn make_editor(content: &str) -> Editor<Buffer, DefaultHost> {
8179        let buf = Buffer::from_str(content);
8180        let host = DefaultHost::new();
8181        Editor::new(buf, host, Options::default())
8182    }
8183
8184    /// `s ba` from [0,0] on "foo bar baz qux\n" → cursor at [0,4] (start of "ba" in "bar").
8185    #[test]
8186    fn sneak_forward_jumps_to_two_char_digraph() {
8187        let mut ed = make_editor("foo bar baz qux\n");
8188        ed.jump_cursor(0, 0);
8189        ed.sneak('b', 'a', true, 1);
8190        assert_eq!(ed.cursor(), (0, 4), "cursor should land on 'ba' in 'bar'");
8191    }
8192
8193    /// `S ba` from [0,12] on "foo bar baz qux\n" → cursor at [0,8] ("ba" in "baz").
8194    #[test]
8195    fn sneak_backward_jumps_to_prior_match() {
8196        let mut ed = make_editor("foo bar baz qux\n");
8197        ed.jump_cursor(0, 12);
8198        ed.sneak('b', 'a', false, 1);
8199        assert_eq!(
8200            ed.cursor(),
8201            (0, 8),
8202            "backward sneak should find 'ba' in 'baz'"
8203        );
8204    }
8205
8206    /// After sneak forward to "bar", `;` (sneak-repeat) jumps to next "ba" ("baz").
8207    #[test]
8208    fn sneak_repeat_semicolon_next_match() {
8209        let mut ed = make_editor("foo bar baz qux\n");
8210        ed.jump_cursor(0, 0);
8211        // First sneak: lands at [0,4]
8212        ed.sneak('b', 'a', true, 1);
8213        assert_eq!(ed.cursor(), (0, 4));
8214        // Repeat via execute_motion FindRepeat (which routes through sneak if last was sneak)
8215        execute_motion(&mut ed, Motion::FindRepeat { reverse: false }, 1);
8216        assert_eq!(ed.cursor(), (0, 8), "semicolon should jump to next 'ba'");
8217    }
8218
8219    /// After sneak forward from [0,0] to [0,4], `,` (reverse) — no prior "ba" → stays.
8220    #[test]
8221    fn sneak_repeat_comma_prev_match() {
8222        let mut ed = make_editor("foo bar baz qux\n");
8223        ed.jump_cursor(0, 0);
8224        ed.sneak('b', 'a', true, 1);
8225        assert_eq!(ed.cursor(), (0, 4));
8226        // Reverse repeat — no "ba" before col 4, so cursor must not move.
8227        let pre = ed.cursor();
8228        execute_motion(&mut ed, Motion::FindRepeat { reverse: true }, 1);
8229        assert_eq!(
8230            ed.cursor(),
8231            pre,
8232            "comma with no prior match should leave cursor unchanged"
8233        );
8234    }
8235
8236    /// `S ba` from [0,12] jumps backward.
8237    #[test]
8238    fn sneak_s_searches_backward() {
8239        let mut ed = make_editor("foo bar baz qux\n");
8240        ed.jump_cursor(0, 12);
8241        ed.sneak('b', 'a', false, 1);
8242        assert_eq!(ed.cursor(), (0, 8));
8243    }
8244
8245    /// `2s ba` from [0,0] jumps to 2nd "ba" occurrence.
8246    #[test]
8247    fn sneak_with_count_jumps_to_nth() {
8248        let mut ed = make_editor("foo bar baz qux\n");
8249        ed.jump_cursor(0, 0);
8250        ed.sneak('b', 'a', true, 2);
8251        assert_eq!(ed.cursor(), (0, 8), "count=2 should jump to 2nd 'ba'");
8252    }
8253
8254    /// `s xx` with no match — cursor stays put.
8255    #[test]
8256    fn sneak_no_match_cursor_stays() {
8257        let mut ed = make_editor("foo bar baz qux\n");
8258        ed.jump_cursor(0, 0);
8259        let pre = ed.cursor();
8260        ed.sneak('x', 'x', true, 1);
8261        assert_eq!(ed.cursor(), pre, "no match should leave cursor unchanged");
8262    }
8263
8264    /// `dsab` on "hello ab world\n" from [0,0] → deletes up to 'ab', leaving "ab world\n".
8265    #[test]
8266    fn operator_pending_dsab_deletes_to_digraph() {
8267        let mut ed = make_editor("hello ab world\n");
8268        ed.jump_cursor(0, 0);
8269        ed.apply_op_sneak(Operator::Delete, 'a', 'b', true, 1);
8270        // Buffer content after exclusive delete from [0,0] to [0,6] (start of "ab").
8271        let content = ed.content();
8272        assert!(
8273            content.starts_with("ab world"),
8274            "dsab should delete 'hello ' leaving 'ab world'; got: {content:?}"
8275        );
8276    }
8277
8278    /// Cross-line sneak: "foo\nbar baz\n", cursor [0,0], `s ba` → [1,0].
8279    #[test]
8280    fn sneak_cross_line_match() {
8281        let mut ed = make_editor("foo\nbar baz\n");
8282        ed.jump_cursor(0, 0);
8283        ed.sneak('b', 'a', true, 1);
8284        assert_eq!(ed.cursor(), (1, 0), "sneak should cross line boundary");
8285    }
8286
8287    /// `last_sneak` is updated after `sneak()` so `;`/`,` can repeat.
8288    #[test]
8289    fn sneak_updates_last_sneak_state() {
8290        let mut ed = make_editor("foo bar baz\n");
8291        ed.jump_cursor(0, 0);
8292        ed.sneak('b', 'a', true, 1);
8293        let ls = ed.last_sneak();
8294        assert_eq!(
8295            ls,
8296            Some((('b', 'a'), true)),
8297            "last_sneak should record the digraph and direction"
8298        );
8299    }
8300}