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