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
74pub use hjkl_vim_types::{
75    Abbrev, AbbrevKind, AbbrevTrigger, CHANGE_LIST_MAX, InsertDir, InsertEntry, InsertReason,
76    InsertSession, JUMPLIST_MAX, LastChange, LastHorizontalMotion, LastVisual, Mode, Motion,
77    Operator, Pending, RangeKind, SEARCH_HISTORY_MAX, ScrollDir, SearchPrompt, TextObject,
78};
79
80use crate::VimMode;
81use crate::input::{Input, Key};
82
83use crate::buf_helpers::{
84    buf_cursor_pos, buf_line, buf_line_bytes, buf_line_chars, buf_row_count, buf_set_cursor_pos,
85    buf_set_cursor_rc,
86};
87use crate::editor::Editor;
88
89// ─── Modes & parser state ───────────────────────────────────────────────────
90
91// ─── Operator / Motion / TextObject ────────────────────────────────────────
92
93/// Vim caps count prefixes at 999,999,999 (`:h count`); mirror that cap on
94/// every value that can feed a `0..count` apply loop so a pathological digit
95/// stream (`<20 nines>x`) saturating toward `usize::MAX` can't freeze the
96/// editor. Matches `CountAccumulator::MAX_COUNT` in hjkl-vim.
97pub const MAX_COUNT: usize = 999_999_999;
98
99/// ROT13 a string: rotate ASCII letters by 13, leave everything else.
100pub(crate) fn rot13_str(s: &str) -> String {
101    s.chars()
102        .map(|c| match c {
103            'a'..='z' => (((c as u8 - b'a' + 13) % 26) + b'a') as char,
104            'A'..='Z' => (((c as u8 - b'A' + 13) % 26) + b'A') as char,
105            _ => c,
106        })
107        .collect()
108}
109
110// ─── Dot-repeat storage ────────────────────────────────────────────────────
111
112// ─── VimState ──────────────────────────────────────────────────────────────
113
114#[derive(Default)]
115pub struct VimState {
116    /// Internal FSM mode. Kept in sync with `current_mode` after every
117    /// `step`. Phase 6.6b: promoted from private to `pub` so the FSM
118    /// body (moving to hjkl-vim in 6.6c–6.6g) can read/write it directly
119    /// until the migration is complete.
120    pub mode: Mode,
121    /// Two-key chord in progress. `Pending::None` when idle.
122    pub pending: Pending,
123    /// Digit prefix accumulated before an operator or motion. `0` means
124    /// no prefix was typed (treated as 1 by most commands).
125    pub count: usize,
126    /// Last `f`/`F`/`t`/`T` target, for `;` / `,` repeat.
127    pub last_find: Option<(char, bool, bool)>,
128    /// Transient: set while resolving a `;` / `,` repeat so the following
129    /// `t`/`T` find skips an immediately-adjacent match (vim's repeat quirk).
130    /// Read and cleared by the `Motion::Find` cursor dispatch.
131    pub find_repeat_skip: bool,
132    /// Most-recent mutating command for `.` dot-repeat.
133    pub last_change: Option<LastChange>,
134    /// Captured on insert-mode entry: count, buffer snapshot, entry kind.
135    pub insert_session: Option<InsertSession>,
136    /// (row, col) anchor for char-wise Visual mode. Set on entry, used
137    /// to compute the highlight range and the operator range without
138    /// relying on tui-textarea's live selection.
139    pub visual_anchor: (usize, usize),
140    /// Row anchor for VisualLine mode.
141    pub visual_line_anchor: usize,
142    /// (row, col) anchor for VisualBlock mode. The live cursor is the
143    /// opposite corner.
144    pub block_anchor: (usize, usize),
145    /// Intended "virtual" column for the block's active corner. j/k
146    /// clamp cursor.col to shorter rows, which would collapse the
147    /// block across ragged content — so we remember the desired column
148    /// separately and use it for block bounds / insert-column
149    /// computations. Updated by h/l only.
150    pub block_vcol: usize,
151    /// Track whether the last yank/cut was linewise (drives `p`/`P` layout).
152    pub yank_linewise: bool,
153    /// Active register selector — set by `"reg` prefix, consumed by
154    /// the next y / d / c / p. `None` falls back to the unnamed `"`.
155    pub pending_register: Option<char>,
156    /// Recording target — set by `q{reg}`, cleared by a bare `q`.
157    /// While `Some`, every consumed `Input` is appended to
158    /// `recording_keys`.
159    pub recording_macro: Option<char>,
160    /// Keys recorded into the in-progress macro. On `q` finish, these
161    /// are encoded via [`crate::input::encode_macro`] and written to
162    /// the matching named register slot, so macros and yanks share a
163    /// single store.
164    pub recording_keys: Vec<crate::input::Input>,
165    /// Set during `@reg` replay so the recorder doesn't capture the
166    /// replayed keystrokes a second time.
167    pub replaying_macro: bool,
168    /// Last register played via `@reg`. `@@` re-plays this one.
169    pub last_macro: Option<char>,
170    /// Position where the cursor was when insert mode last exited (Esc).
171    /// Used by `gi` to return to the exact (row, col) where the user
172    /// last typed, matching vim's `:h gi`.
173    pub last_insert_pos: Option<(usize, usize)>,
174    /// Snapshot of the last visual selection for `gv` re-entry.
175    /// Stored on every Visual / VisualLine / VisualBlock exit.
176    pub last_visual: Option<LastVisual>,
177    /// `zz` / `zt` / `zb` set this so the end-of-step scrolloff
178    /// pass doesn't override the user's explicit viewport pinning.
179    /// Cleared every step.
180    pub viewport_pinned: bool,
181    /// Set by the 7 smooth-scrollable motions (C-d/u/f/b, zz/zt/zb) so the
182    /// app can animate the viewport jump. Drained via Editor::take_scroll_anim_hint.
183    pub scroll_anim_hint: bool,
184    /// Set while replaying `.` / last-change so we don't re-record it.
185    pub replaying: bool,
186    /// Entered Normal from Insert via `Ctrl-o`; after the next complete
187    /// normal-mode command we return to Insert.
188    pub one_shot_normal: bool,
189    /// Live `/` or `?` prompt. `None` outside search-prompt mode.
190    pub search_prompt: Option<SearchPrompt>,
191    /// Most recent committed search pattern. Surfaced to host apps via
192    /// [`Editor::last_search`] so their status line can render a hint
193    /// and so `n` / `N` have something to repeat.
194    pub last_search: Option<String>,
195    /// Direction of the last committed search. `n` repeats this; `N`
196    /// inverts it. Defaults to forward so a never-searched buffer's
197    /// `n` still walks downward.
198    pub last_search_forward: bool,
199    /// Text of the most recent insert session — vim's `".` register, pasted
200    /// via `<C-r>.` in insert mode (and `".p` in normal mode).
201    pub last_insert_text: Option<String>,
202    /// Back half of the jumplist — `Ctrl-o` pops from here. Populated
203    /// with the pre-motion cursor when a "big jump" motion fires
204    /// (`gg`/`G`, `%`, `*`/`#`, `n`/`N`, `H`/`M`/`L`, committed `/` or
205    /// `?`). Capped at 100 entries.
206    pub jump_back: Vec<(usize, usize)>,
207    /// Forward half — `Ctrl-i` pops from here. Cleared by any new big
208    /// jump, matching vim's "branch off trims forward history" rule.
209    pub jump_fwd: Vec<(usize, usize)>,
210    /// Set by `Ctrl-R` in insert mode while waiting for the register
211    /// selector. The next typed char names the register; its contents
212    /// are inserted inline at the cursor and the flag clears.
213    pub insert_pending_register: bool,
214    /// Stashed start position for the `[` mark on a Change operation.
215    /// Set to `top` before the cut in `run_operator_over_range` (Change
216    /// arm); consumed by `finish_insert_session` on Esc-from-insert
217    /// when the reason is `AfterChange`. Mirrors vim's `:h '[` / `:h ']`
218    /// rule that `[` = start of change, `]` = last typed char on exit.
219    pub change_mark_start: Option<(usize, usize)>,
220    /// Bounded history of committed `/` / `?` search patterns. Newest
221    /// entries are at the back; capped at [`SEARCH_HISTORY_MAX`] to
222    /// avoid unbounded growth on long sessions.
223    pub search_history: Vec<String>,
224    /// Index into `search_history` while the user walks past patterns
225    /// in the prompt via `Ctrl-P` / `Ctrl-N`. `None` outside that walk
226    /// — typing or backspacing in the prompt resets it so the next
227    /// `Ctrl-P` starts from the most recent entry again.
228    pub search_history_cursor: Option<usize>,
229    /// Wall-clock instant of the last keystroke. Drives the
230    /// `:set timeoutlen` multi-key timeout — if `now() - last_input_at`
231    /// exceeds the configured budget, any pending prefix is cleared
232    /// before the new key dispatches. `None` before the first key.
233    /// 0.0.29 (Patch B): `:set timeoutlen` math now reads
234    /// [`crate::types::Host::now`] via `last_input_host_at`. This
235    /// `Instant`-flavoured field stays for snapshot tests that still
236    /// observe it directly.
237    pub last_input_at: Option<std::time::Instant>,
238    /// `Host::now()` reading at the last keystroke. Drives
239    /// `:set timeoutlen` so macro replay / headless drivers stay
240    /// deterministic regardless of wall-clock skew.
241    pub last_input_host_at: Option<core::time::Duration>,
242    /// Canonical current mode. Mirrors `mode` (the FSM-internal field)
243    /// AND is written by every Phase 6.3 primitive (`set_mode`,
244    /// `enter_visual_char_bridge`, …). Once the FSM is gone this is the
245    /// sole source of truth; until then both fields are kept in sync.
246    /// Initialized to `Normal` via `#[derive(Default)]`.
247    pub current_mode: crate::VimMode,
248    /// Most recent successful :s invocation. Stored so :& / :&& can repeat it.
249    pub last_substitute: Option<crate::substitute::SubstituteCmd>,
250    /// Stack of auto-inserted closing characters awaiting skip-over.
251    ///
252    /// Each entry `(row, col, ch)` records where autopair placed a close
253    /// character. When the next typed char matches `ch` AND the cursor is
254    /// immediately before that position, the engine advances past it
255    /// ("skip-over") instead of inserting. The stack is cleared on any
256    /// cursor motion, mode change, or out-of-pair edit.
257    pub pending_closes: Vec<(usize, usize, char)>,
258    /// Last sneak digraph and direction: `Some(((c1, c2), forward))`.
259    /// Used by `;` / `,` sneak-repeat when `last_horizontal_motion == Sneak`.
260    pub last_sneak: Option<((char, char), bool)>,
261    /// Tracks which kind of horizontal motion was last performed, so `;` / `,`
262    /// can dispatch to sneak-repeat vs. find-char-repeat as appropriate.
263    pub last_horizontal_motion: LastHorizontalMotion,
264    /// Insert-mode (and cmdline-mode) abbreviations. Populated by `:abbreviate`,
265    /// `:iabbrev`, `:cabbrev`, `:noreabbrev`, etc. Empty by default.
266    pub abbrevs: Vec<Abbrev>,
267}
268
269impl VimState {
270    pub fn public_mode(&self) -> VimMode {
271        match self.mode {
272            Mode::Normal => VimMode::Normal,
273            Mode::Insert => VimMode::Insert,
274            Mode::Visual => VimMode::Visual,
275            Mode::VisualLine => VimMode::VisualLine,
276            Mode::VisualBlock => VimMode::VisualBlock,
277        }
278    }
279
280    pub fn force_normal(&mut self) {
281        self.mode = Mode::Normal;
282        self.pending = Pending::None;
283        self.count = 0;
284        self.insert_session = None;
285        // Phase 6.3: keep current_mode in sync for callers that bypass step().
286        self.current_mode = crate::VimMode::Normal;
287    }
288
289    /// Reset every prefix-tracking field so the next keystroke starts
290    /// a fresh sequence. Drives `:set timeoutlen` — when the user
291    /// pauses past the configured budget, `hjkl_vim::dispatch_input` calls
292    /// this before dispatching the new key.
293    ///
294    /// Resets: `pending`, `count`, `pending_register`,
295    /// `insert_pending_register`. Does NOT touch `mode`,
296    /// `insert_session`, marks, jump list, or visual anchors —
297    /// those aren't part of the in-flight chord.
298    pub fn clear_pending_prefix(&mut self) {
299        self.pending = Pending::None;
300        self.count = 0;
301        self.pending_register = None;
302        self.insert_pending_register = false;
303    }
304
305    /// Widen the active insert session's row window to include `row`. Called
306    /// by the Phase 6.1 public `Editor::insert_*` methods after each
307    /// mutation so `finish_insert_session` diffs the right range on Esc.
308    /// No-op when no insert session is active (e.g. calling from Normal mode).
309    pub(crate) fn widen_insert_row(&mut self, row: usize) {
310        if let Some(ref mut session) = self.insert_session {
311            session.row_min = session.row_min.min(row);
312            session.row_max = session.row_max.max(row);
313        }
314    }
315
316    pub fn is_visual(&self) -> bool {
317        matches!(
318            self.mode,
319            Mode::Visual | Mode::VisualLine | Mode::VisualBlock
320        )
321    }
322
323    pub fn is_visual_char(&self) -> bool {
324        self.mode == Mode::Visual
325    }
326
327    /// The pending repeat count (typed digits before a motion/operator),
328    /// or `None` when no digits are pending. Zero is treated as absent.
329    pub(crate) fn pending_count_val(&self) -> Option<u32> {
330        if self.count == 0 {
331            None
332        } else {
333            Some(self.count as u32)
334        }
335    }
336
337    /// `true` when an in-flight chord is awaiting more keys. Inverse of
338    /// `matches!(self.pending, Pending::None)`.
339    pub(crate) fn is_chord_pending(&self) -> bool {
340        !matches!(self.pending, Pending::None)
341    }
342
343    /// Return a single char representing the pending operator, if any.
344    /// Used by host apps (status line "showcmd" area) to display e.g.
345    /// `d`, `y`, `c` while waiting for a motion.
346    pub(crate) fn pending_op_char(&self) -> Option<char> {
347        let op = match &self.pending {
348            Pending::Op { op, .. }
349            | Pending::OpTextObj { op, .. }
350            | Pending::OpG { op, .. }
351            | Pending::OpFind { op, .. }
352            | Pending::OpSquareBracketOpen { op, .. }
353            | Pending::OpSquareBracketClose { op, .. } => Some(*op),
354            _ => None,
355        };
356        op.map(|o| match o {
357            Operator::Delete => 'd',
358            Operator::Change => 'c',
359            Operator::Yank => 'y',
360            Operator::Uppercase => 'U',
361            Operator::Lowercase => 'u',
362            Operator::ToggleCase => '~',
363            Operator::Indent => '>',
364            Operator::Outdent => '<',
365            Operator::Fold => 'z',
366            Operator::Reflow => 'q',
367            Operator::ReflowKeepCursor => 'w',
368            Operator::AutoIndent => '=',
369            Operator::Filter => '!',
370            // `gc` prefix — doubled as `gcc`.
371            Operator::Comment => 'c',
372            // `g?` prefix — doubled as `g??`.
373            Operator::Rot13 => '?',
374        })
375    }
376}
377
378// ─── Entry point ───────────────────────────────────────────────────────────
379
380/// Open the `/` (forward) or `?` (backward) search prompt. Clears any
381/// live search highlight until the user commits a query. `last_search`
382/// is preserved so an empty `<CR>` can re-run the previous pattern.
383pub(crate) fn enter_search<H: crate::types::Host>(
384    ed: &mut Editor<hjkl_buffer::Buffer, H>,
385    forward: bool,
386) {
387    ed.vim.search_prompt = Some(SearchPrompt {
388        text: String::new(),
389        cursor: 0,
390        forward,
391        operator: None,
392    });
393    ed.vim.search_history_cursor = None;
394    // 0.0.37: clear via the engine search state (the buffer-side
395    // bridge from 0.0.35 was removed in this patch — the `BufferView`
396    // renderer reads the pattern from `Editor::search_state()`).
397    ed.set_search_pattern(None);
398}
399
400/// `d/pat` / `c/pat` / `y/pat` (and `?` forms) — open the search prompt in
401/// operator-pending mode. On commit the operator runs over the exclusive
402/// charwise range from the current cursor to the match.
403pub(crate) fn enter_search_op<H: crate::types::Host>(
404    ed: &mut Editor<hjkl_buffer::Buffer, H>,
405    forward: bool,
406    op: Operator,
407    count: usize,
408) {
409    let origin = ed.cursor();
410    ed.vim.search_prompt = Some(SearchPrompt {
411        text: String::new(),
412        cursor: 0,
413        forward,
414        operator: Some((op, count.max(1), origin)),
415    });
416    ed.vim.search_history_cursor = None;
417    ed.set_search_pattern(None);
418}
419
420/// Apply a pending operator-search over the exclusive charwise range from
421/// `origin` to the current (post-search) cursor. Used by the search-prompt
422/// commit path for `d/` / `c/` / `y/`.
423pub(crate) fn apply_op_search_range<H: crate::types::Host>(
424    ed: &mut Editor<hjkl_buffer::Buffer, H>,
425    op: Operator,
426    origin: (usize, usize),
427) {
428    let target = ed.cursor();
429    run_operator_over_range(ed, op, origin, target, RangeKind::Exclusive);
430}
431
432/// `g;` / `g,` body. `dir = -1` walks toward older entries (g;),
433/// `dir = 1` toward newer (g,). `count` repeats the step. Stops at
434/// the ends of the ring; off-ring positions are silently ignored.
435fn walk_change_list<H: crate::types::Host>(
436    ed: &mut Editor<hjkl_buffer::Buffer, H>,
437    dir: isize,
438    count: usize,
439) {
440    if ed.change_list.is_empty() {
441        return;
442    }
443    let len = ed.change_list.len();
444    let mut idx: isize = match (ed.change_list_cursor, dir) {
445        (None, -1) => len as isize - 1,
446        (None, 1) => return, // already past the newest entry
447        (Some(i), -1) => i as isize - 1,
448        (Some(i), 1) => i as isize + 1,
449        _ => return,
450    };
451    for _ in 1..count {
452        let next = idx + dir;
453        if next < 0 || next >= len as isize {
454            break;
455        }
456        idx = next;
457    }
458    if idx < 0 || idx >= len as isize {
459        return;
460    }
461    let idx = idx as usize;
462    ed.change_list_cursor = Some(idx);
463    let (row, col) = ed.change_list[idx];
464    ed.jump_cursor(row, col);
465}
466
467/// `Ctrl-R {reg}` body — insert the named register's contents at the
468/// cursor as charwise text. Embedded newlines split lines naturally via
469/// `Edit::InsertStr`. Unknown selectors and empty slots are no-ops so
470/// stray keystrokes don't mutate the buffer.
471fn insert_register_text<H: crate::types::Host>(
472    ed: &mut Editor<hjkl_buffer::Buffer, H>,
473    selector: char,
474) {
475    use hjkl_buffer::Edit;
476    // Special read-only registers: `/` = last search pattern, `.` = last
477    // inserted text. Fall back to the register store for everything else.
478    let text = match selector {
479        '/' => match &ed.vim.last_search {
480            Some(s) if !s.is_empty() => s.clone(),
481            _ => return,
482        },
483        '.' => match &ed.vim.last_insert_text {
484            Some(s) if !s.is_empty() => s.clone(),
485            _ => return,
486        },
487        _ => match ed.registers().read(selector) {
488            Some(slot) if !slot.text.is_empty() => slot.text.clone(),
489            _ => return,
490        },
491    };
492    ed.sync_buffer_content_from_textarea();
493    let cursor = buf_cursor_pos(&ed.buffer);
494    ed.mutate_edit(Edit::InsertStr {
495        at: cursor,
496        text: text.clone(),
497    });
498    // Advance cursor to the end of the inserted payload — multi-line
499    // pastes land on the last inserted row at the post-text column.
500    let mut row = cursor.row;
501    let mut col = cursor.col;
502    for ch in text.chars() {
503        if ch == '\n' {
504            row += 1;
505            col = 0;
506        } else {
507            col += 1;
508        }
509    }
510    buf_set_cursor_rc(&mut ed.buffer, row, col);
511    ed.push_buffer_cursor_to_textarea();
512    ed.mark_content_dirty();
513    if let Some(ref mut session) = ed.vim.insert_session {
514        session.row_min = session.row_min.min(row);
515        session.row_max = session.row_max.max(row);
516    }
517}
518
519/// Compute the indent string to insert at the start of a new line
520/// after Enter is pressed at `cursor`. Walks the smartindent rules:
521///
522/// - autoindent off → empty string
523/// - autoindent on  → copy prev line's leading whitespace
524/// - smartindent on → bump one `shiftwidth` if prev line's last
525///   non-whitespace char is `{` / `(` / `[`
526///
527/// Indent unit (used for the smartindent bump):
528///
529/// - `expandtab && softtabstop > 0` → `softtabstop` spaces
530/// - `expandtab` → `shiftwidth` spaces
531/// - `!expandtab` → one literal `\t`
532///
533/// This is the placeholder for a future tree-sitter indent provider:
534/// when a language has an `indents.scm` query, the engine will route
535/// the same call through that provider and only fall back to this
536/// heuristic when no query matches.
537pub(super) fn compute_enter_indent(settings: &crate::editor::Settings, prev_line: &str) -> String {
538    if !settings.autoindent {
539        return String::new();
540    }
541    // Copy the prev line's leading whitespace (autoindent base).
542    let base: String = prev_line
543        .chars()
544        .take_while(|c| *c == ' ' || *c == '\t')
545        .collect();
546
547    if settings.smartindent {
548        let unit = if settings.expandtab {
549            if settings.softtabstop > 0 {
550                " ".repeat(settings.softtabstop)
551            } else {
552                " ".repeat(settings.shiftwidth)
553            }
554        } else {
555            "\t".to_string()
556        };
557
558        // Open-bracket bump — language-agnostic.
559        let last_non_ws = prev_line.chars().rev().find(|c| !c.is_whitespace());
560        if matches!(last_non_ws, Some('{' | '(' | '[')) {
561            return format!("{base}{unit}");
562        }
563
564        // HTML-family opening-tag bump: `<head>` / `<div class="...">`.
565        // Gated on filetype so Rust generics like `Vec<T>` don't trigger.
566        // Reuses scan_tag_opener which already filters self-closing and
567        // void elements.
568        if is_html_filetype(&settings.filetype) {
569            let trimmed_end_len = prev_line
570                .trim_end_matches(|c: char| c.is_whitespace())
571                .len();
572            let trimmed = &prev_line[..trimmed_end_len];
573            if let Some(stripped) = trimmed.strip_suffix('>')
574                && scan_tag_opener(trimmed, stripped.len()).is_some()
575            {
576                return format!("{base}{unit}");
577            }
578        }
579    }
580
581    base
582}
583
584// ── Comment-continuation helpers ──────────────────────────────────────────
585
586/// Return the ordered (longest-first) list of line-comment prefixes for
587/// `lang`. Each prefix includes one trailing space (e.g. `"// "`).
588/// The same table lives in `hjkl-lang::comment` for the `gc` toggle (#187).
589fn comment_prefixes_for_lang(lang: &str) -> &'static [&'static str] {
590    match lang {
591        "rust" => &["/// ", "//! ", "// "],
592        "c" | "cpp" => &["// "],
593        "python" | "sh" | "bash" | "zsh" | "fish" | "toml" | "yaml" => &["# "],
594        "lua" => &["-- "],
595        "sql" => &["-- "],
596        "vim" | "viml" => &["\" "],
597        _ => &[],
598    }
599}
600
601/// Detect whether `line` starts with a known comment prefix for `lang`.
602///
603/// Returns `Some((indent, prefix))` where `indent` is the leading whitespace
604/// of the line and `prefix` is the canonical (with trailing space) comment
605/// marker. Returns `None` when the line is not a recognised comment.
606pub(crate) fn detect_comment_on_line(lang: &str, line: &str) -> Option<(String, &'static str)> {
607    let indent_end = line
608        .char_indices()
609        .find(|(_, c)| *c != ' ' && *c != '\t')
610        .map(|(i, _)| i)
611        .unwrap_or(line.len());
612    let indent = line[..indent_end].to_string();
613    let rest = &line[indent_end..];
614    for &prefix in comment_prefixes_for_lang(lang) {
615        if rest.starts_with(prefix) {
616            return Some((indent, prefix));
617        }
618        // Also match the bare prefix (line that is exactly `//` with no
619        // trailing content).
620        let bare = prefix.trim_end_matches(' ');
621        if rest == bare || rest.starts_with(&format!("{bare} ")) {
622            return Some((indent, prefix));
623        }
624    }
625    None
626}
627
628/// Given the current `row` in `buffer` and the active `settings`, return the
629/// string to prepend on the new line when comment-continuation fires.
630///
631/// Returns `Some("<indent><prefix>")` when the row is a comment line and
632/// continuation is appropriate, `None` otherwise. The caller appends the
633/// string after the `\n` they are about to insert.
634pub(crate) fn continue_comment(
635    buffer: &hjkl_buffer::Buffer,
636    settings: &crate::editor::Settings,
637    row: usize,
638) -> Option<String> {
639    if settings.filetype.is_empty() {
640        return None;
641    }
642    let line = crate::buf_helpers::buf_line(buffer, row)?;
643    let (indent, prefix) = detect_comment_on_line(&settings.filetype, &line)?;
644    Some(format!("{indent}{prefix}"))
645}
646
647/// Strip one indent unit from the beginning of `line` and insert `ch`
648/// instead. Returns `true` when it consumed the keystroke (dedent +
649/// insert), `false` when the caller should insert normally.
650///
651/// Dedent fires when:
652///   - `smartindent` is on
653///   - `ch` is `}` / `)` / `]`
654///   - all bytes BEFORE the cursor on the current line are whitespace
655///   - there is at least one full indent unit of leading whitespace
656fn try_dedent_close_bracket<H: crate::types::Host>(
657    ed: &mut Editor<hjkl_buffer::Buffer, H>,
658    cursor: hjkl_buffer::Position,
659    ch: char,
660) -> bool {
661    use hjkl_buffer::{Edit, MotionKind, Position};
662
663    if !ed.settings.smartindent {
664        return false;
665    }
666    if !matches!(ch, '}' | ')' | ']') {
667        return false;
668    }
669
670    let line = match buf_line(&ed.buffer, cursor.row) {
671        Some(l) => l.to_string(),
672        None => return false,
673    };
674
675    // All chars before cursor must be whitespace.
676    let before: String = line.chars().take(cursor.col).collect();
677    if !before.chars().all(|c| c == ' ' || c == '\t') {
678        return false;
679    }
680    if before.is_empty() {
681        // Nothing to strip — just insert normally (cursor at col 0).
682        return false;
683    }
684
685    // Compute indent unit.
686    let unit_len: usize = if ed.settings.expandtab {
687        if ed.settings.softtabstop > 0 {
688            ed.settings.softtabstop
689        } else {
690            ed.settings.shiftwidth
691        }
692    } else {
693        // Tab: one literal tab character.
694        1
695    };
696
697    // Check there's at least one full unit to strip.
698    let strip_len = if ed.settings.expandtab {
699        // Count leading spaces; need at least `unit_len`.
700        let spaces = before.chars().filter(|c| *c == ' ').count();
701        if spaces < unit_len {
702            return false;
703        }
704        unit_len
705    } else {
706        // noexpandtab: strip one leading tab.
707        if !before.starts_with('\t') {
708            return false;
709        }
710        1
711    };
712
713    // Delete the leading `strip_len` chars of the current line.
714    ed.mutate_edit(Edit::DeleteRange {
715        start: Position::new(cursor.row, 0),
716        end: Position::new(cursor.row, strip_len),
717        kind: MotionKind::Char,
718    });
719    // Insert the close bracket at column 0 (after the delete the cursor
720    // is still positioned at the end of the remaining whitespace; the
721    // delete moved the text so the cursor is now at col = before.len() -
722    // strip_len).
723    let new_col = cursor.col.saturating_sub(strip_len);
724    ed.mutate_edit(Edit::InsertChar {
725        at: Position::new(cursor.row, new_col),
726        ch,
727    });
728    true
729}
730
731fn finish_insert_session<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
732    let Some(session) = ed.vim.insert_session.take() else {
733        return;
734    };
735    let after_rope = crate::types::Query::rope(&ed.buffer);
736    // Clamp both slices to their respective bounds — the buffer may have
737    // grown (Enter splits rows) or shrunk (Backspace joins rows) during
738    // the session, so row_max can overshoot either side.
739    let before_n = session.before_rope.len_lines();
740    let after_n = after_rope.len_lines();
741    let after_end = session.row_max.min(after_n.saturating_sub(1));
742    let before_end = session.row_max.min(before_n.saturating_sub(1));
743    let before = if before_end >= session.row_min && session.row_min < before_n {
744        rope_row_range_str(&session.before_rope, session.row_min, before_end)
745    } else {
746        String::new()
747    };
748    let after = if after_end >= session.row_min && session.row_min < after_n {
749        rope_row_range_str(&after_rope, session.row_min, after_end)
750    } else {
751        String::new()
752    };
753    // `R` overstrike keeps the line length the same, so `extract_inserted`
754    // (which only reports net growth) misses the typed text. Use the changed
755    // run instead so dot-repeat retypes it.
756    let inserted = if matches!(session.reason, InsertReason::Replace) {
757        changed_run(&before, &after)
758    } else {
759        extract_inserted(&before, &after)
760    };
761    // vim `".` register — text of the most recent insert.
762    if !ed.vim.replaying && !inserted.is_empty() {
763        ed.vim.last_insert_text = Some(inserted.clone());
764    }
765    let open_line = matches!(session.reason, InsertReason::Open { .. });
766    if session.count > 1 && !ed.vim.replaying {
767        use hjkl_buffer::{Edit, Position};
768        if open_line {
769            // `[count]o` / `[count]O` open `count` SEPARATE lines, each with the
770            // typed text. Read the just-opened line's content directly (the
771            // row-range extract above is unreliable across the open boundary)
772            // and stack `count - 1` further lines below it.
773            let (start_row, _) = ed.cursor();
774            let typed = buf_line(&ed.buffer, start_row).unwrap_or_default();
775            for at_row in start_row..start_row + (session.count - 1) {
776                let end = buf_line_chars(&ed.buffer, at_row);
777                ed.mutate_edit(Edit::InsertStr {
778                    at: Position::new(at_row, end),
779                    text: format!("\n{typed}"),
780                });
781            }
782        } else if !inserted.is_empty() {
783            // `[count]i` / `[count]A` repeat the typed text inline.
784            for _ in 0..session.count - 1 {
785                let (row, col) = ed.cursor();
786                ed.mutate_edit(Edit::InsertStr {
787                    at: Position::new(row, col),
788                    text: inserted.clone(),
789                });
790            }
791        }
792    }
793    // Helper: replicate `inserted` text across block rows top+1..=bot at `col`,
794    // padding short rows to reach `col` first. Returns without touching the
795    // cursor — callers position the cursor afterward according to their needs.
796    fn replicate_block_text<H: crate::types::Host>(
797        ed: &mut Editor<hjkl_buffer::Buffer, H>,
798        inserted: &str,
799        top: usize,
800        bot: usize,
801        col: usize,
802    ) {
803        use hjkl_buffer::{Edit, Position};
804        for r in (top + 1)..=bot {
805            let line_len = buf_line_chars(&ed.buffer, r);
806            if col > line_len {
807                let pad: String = std::iter::repeat_n(' ', col - line_len).collect();
808                ed.mutate_edit(Edit::InsertStr {
809                    at: Position::new(r, line_len),
810                    text: pad,
811                });
812            }
813            ed.mutate_edit(Edit::InsertStr {
814                at: Position::new(r, col),
815                text: inserted.to_string(),
816            });
817        }
818    }
819
820    if let InsertReason::BlockEdge { top, bot, col } = session.reason {
821        // `I` / `A` from VisualBlock: replicate text across rows; cursor
822        // stays at the block-start column (vim leaves cursor there).
823        if !inserted.is_empty() && top < bot && !ed.vim.replaying {
824            replicate_block_text(ed, &inserted, top, bot, col);
825            buf_set_cursor_rc(&mut ed.buffer, top, col);
826            ed.push_buffer_cursor_to_textarea();
827        }
828        return;
829    }
830    if let InsertReason::BlockChange { top, bot, col } = session.reason {
831        // `c` from VisualBlock: replicate text across rows; cursor advances
832        // to `col + ins_chars` (pre-step-back) so the Esc step-back lands
833        // on the last typed char (col + ins_chars - 1), matching nvim.
834        if !inserted.is_empty() && top < bot && !ed.vim.replaying {
835            replicate_block_text(ed, &inserted, top, bot, col);
836            let ins_chars = inserted.chars().count();
837            let line_len = buf_line_chars(&ed.buffer, top);
838            let target_col = (col + ins_chars).min(line_len);
839            buf_set_cursor_rc(&mut ed.buffer, top, target_col);
840            ed.push_buffer_cursor_to_textarea();
841        }
842        return;
843    }
844    if ed.vim.replaying {
845        return;
846    }
847    match session.reason {
848        InsertReason::Enter(entry) => {
849            ed.vim.last_change = Some(LastChange::InsertAt {
850                entry,
851                inserted,
852                count: session.count,
853            });
854        }
855        InsertReason::Open { above } => {
856            ed.vim.last_change = Some(LastChange::OpenLine { above, inserted });
857        }
858        InsertReason::AfterChange => {
859            if let Some(
860                LastChange::OpMotion { inserted: ins, .. }
861                | LastChange::OpTextObj { inserted: ins, .. }
862                | LastChange::LineOp { inserted: ins, .. }
863                | LastChange::GnOp { inserted: ins, .. },
864            ) = ed.vim.last_change.as_mut()
865            {
866                *ins = Some(inserted);
867            }
868            // Vim `:h '[` / `:h ']`: on change, `[` = start of the
869            // changed range (stashed before the cut), `]` = the cursor
870            // at Esc time (last inserted char, before the step-back).
871            // When nothing was typed cursor still sits at the change
872            // start, satisfying vim's "both at start" parity for `c<m><Esc>`.
873            if let Some(start) = ed.vim.change_mark_start.take() {
874                let end = ed.cursor();
875                ed.set_mark('[', start);
876                ed.set_mark(']', end);
877            }
878        }
879        InsertReason::DeleteToEol => {
880            ed.vim.last_change = Some(LastChange::DeleteToEol {
881                inserted: Some(inserted),
882            });
883        }
884        InsertReason::ReplayOnly => {}
885        InsertReason::BlockEdge { .. } => unreachable!("handled above"),
886        InsertReason::BlockChange { .. } => unreachable!("handled above"),
887        InsertReason::Replace => {
888            // `R` overstrike: dot-repeat re-overtypes the same text at the
889            // cursor (vim parity — not a delete-to-EOL).
890            ed.vim.last_change = Some(LastChange::ReplaceMode { text: inserted });
891        }
892    }
893}
894
895pub(crate) fn begin_insert<H: crate::types::Host>(
896    ed: &mut Editor<hjkl_buffer::Buffer, H>,
897    count: usize,
898    reason: InsertReason,
899) {
900    // `nomodifiable`: silently refuse to enter insert/replace; stay in current mode.
901    if !ed.settings.modifiable {
902        return;
903    }
904    // BLAME view: pressing `i` exits blame (drops the overlay) but stays Normal.
905    if ed.view == crate::ViewMode::Blame {
906        ed.view = crate::ViewMode::Normal;
907        return;
908    }
909    let record = !matches!(reason, InsertReason::ReplayOnly);
910    if record {
911        ed.push_undo();
912    }
913    let reason = if ed.vim.replaying {
914        InsertReason::ReplayOnly
915    } else {
916        reason
917    };
918    let (row, col) = ed.cursor();
919    ed.vim.insert_session = Some(InsertSession {
920        count,
921        row_min: row,
922        row_max: row,
923        before_rope: crate::types::Query::rope(&ed.buffer),
924        reason,
925        start_row: row,
926        start_col: col,
927    });
928    ed.vim.mode = Mode::Insert;
929    // Phase 6.3: keep current_mode in sync for callers that bypass step().
930    ed.vim.current_mode = crate::VimMode::Insert;
931    drop_blame_if_left_normal(ed);
932}
933
934/// `:set undobreak` semantics for insert-mode motions. When the
935/// toggle is on, a non-character keystroke that moves the cursor
936/// (arrow keys, Home/End, mouse click) ends the current undo group
937/// and starts a new one mid-session. After this, a subsequent `u`
938/// in normal mode reverts only the post-break run, leaving the
939/// pre-break edits in place — matching vim's behaviour.
940///
941/// Implementation: snapshot the current buffer onto the undo stack
942/// (the new break point) and reset the active `InsertSession`'s
943/// `before_lines` so `finish_insert_session`'s diff window only
944/// captures the post-break run for `last_change` / dot-repeat.
945///
946/// During replay we skip the break — replay shouldn't pollute the
947/// undo stack with intra-replay snapshots.
948pub(crate) fn break_undo_group_in_insert<H: crate::types::Host>(
949    ed: &mut Editor<hjkl_buffer::Buffer, H>,
950) {
951    if !ed.settings.undo_break_on_motion {
952        return;
953    }
954    if ed.vim.replaying {
955        return;
956    }
957    if ed.vim.insert_session.is_none() {
958        return;
959    }
960    ed.push_undo();
961    let before_rope = crate::types::Query::rope(&ed.buffer);
962    let row = crate::types::Cursor::cursor(&ed.buffer).line as usize;
963    if let Some(ref mut session) = ed.vim.insert_session {
964        session.before_rope = before_rope;
965        session.row_min = row;
966        session.row_max = row;
967    }
968}
969
970/// Word-boundary undo break for [`crate::editor::UndoGranularity::Word`].
971///
972/// Called from [`insert_char_bridge`] (before inserting `next`) and from
973/// [`insert_newline_bridge`] (pass `next = '\n'`).
974///
975/// **Heuristic:** a break is inserted when:
976/// - `next` is a non-whitespace char **and** the char immediately before
977///   the cursor is whitespace (or the cursor is at column 0 but not the
978///   session-start position — i.e. the user has already typed something
979///   and then navigated or wrapped to column 0). This corresponds to
980///   "the first character of a new word just after whitespace."
981/// - `next` is `'\n'` (newline always starts a new undo unit).
982///
983/// **No break on the session's very first char:** `begin_insert` already
984/// pushed an undo snapshot; breaking again would create an empty entry.
985/// We detect "first char" by comparing the current cursor position to the
986/// session's `(start_row, start_col)`.
987///
988/// During replay (`ed.vim.replaying`) or when there is no active insert
989/// session, this is a complete no-op — the vim path is unchanged.
990///
991/// When `undo_granularity == InsertSession` this function returns
992/// immediately, adding zero calls to the hot path.
993pub(crate) fn maybe_word_undo_break<H: crate::types::Host>(
994    ed: &mut Editor<hjkl_buffer::Buffer, H>,
995    next: char,
996) {
997    use crate::buf_helpers::{buf_cursor_pos, buf_line};
998    use crate::editor::UndoGranularity;
999
1000    // Fast-path: default (vim) granularity → no-op.
1001    if ed.settings.undo_granularity != UndoGranularity::Word {
1002        return;
1003    }
1004    // No-op during replay or when there is no active insert session.
1005    if ed.vim.replaying {
1006        return;
1007    }
1008    let session = match ed.vim.insert_session.as_ref() {
1009        Some(s) => s,
1010        None => return,
1011    };
1012
1013    let cursor = buf_cursor_pos(&ed.buffer);
1014
1015    // Skip the very first inserted char (begin_insert already snapshotted).
1016    let is_first_pos = cursor.row == session.start_row && cursor.col == session.start_col;
1017    if is_first_pos {
1018        return;
1019    }
1020
1021    // Newline always breaks.
1022    let should_break = if next == '\n' {
1023        true
1024    } else if next.is_whitespace() {
1025        // Whitespace chars do not start a new word.
1026        false
1027    } else {
1028        // Non-whitespace: break only when the char immediately before the
1029        // cursor is whitespace (entering a word from whitespace territory).
1030        let prev_char = buf_line(&ed.buffer, cursor.row)
1031            .as_deref()
1032            .and_then(|line| line.chars().nth(cursor.col.wrapping_sub(1)));
1033        match prev_char {
1034            // Previous char is whitespace → this is a word start.
1035            Some(p) if p.is_whitespace() => true,
1036            // cursor.col == 0 means we arrived here from another line:
1037            // that too is a word-start boundary (newline already handled
1038            // above for the \n itself; this handles the first char on the
1039            // new line after the newline was inserted as a prior break).
1040            None if cursor.col == 0 => false, // col-0 on first position covered by start check above
1041            _ => false,
1042        }
1043    };
1044
1045    if should_break {
1046        // Reuse the existing mid-session break machinery: push a snapshot
1047        // and reset session.before_rope + row_min/row_max.
1048        ed.push_undo();
1049        let before_rope = crate::types::Query::rope(&ed.buffer);
1050        let row = cursor.row;
1051        if let Some(ref mut session) = ed.vim.insert_session {
1052            session.before_rope = before_rope;
1053            session.row_min = row;
1054            session.row_max = row;
1055        }
1056    }
1057}
1058
1059// ─── Phase 6.1: public insert-mode primitives ──────────────────────────────
1060//
1061// Each `pub(crate)` free function below implements one insert-mode action.
1062// hjkl-vim's insert dispatcher calls them through `Editor::insert_*` methods.
1063// External callers can also invoke the public Editor methods directly.
1064//
1065// Invariants every function upholds:
1066//   - Opens with `ed.sync_buffer_content_from_textarea()` (no-op, kept for
1067//     forward compatibility once textarea is gone).
1068//   - All buffer mutations go through `ed.mutate_edit(...)` so dirty flag,
1069//     undo, change-list, content-edit fan-out all fire uniformly.
1070//   - Navigation-only functions call `break_undo_group_in_insert` when the
1071//     FSM did so, then return `false` (no mutation).
1072//   - After mutations, `ed.push_buffer_cursor_to_textarea()` is called
1073//     (currently a no-op but kept for migration hygiene).
1074//   - Returns `true` when the buffer was mutated, `false` otherwise.
1075
1076/// Return the filetype-gated autopair close character for `open`, or `None`
1077/// when no pairing applies.
1078///
1079/// Rules:
1080/// - `(` → `)`, `[` → `]`, `{` → `}` always.
1081/// - `"` → `"` and `` ` `` → `` ` `` always, EXCEPT when the previous two
1082///   characters are the same quote — typing the third `` ` `` of a markdown
1083///   code-fence or the third `"` of a Python triple-quoted string must
1084///   emit a bare quote (no close) so the result is `` ``` `` / `"""` and
1085///   not `` ```` `` / `""""`.
1086/// - `<` → `>` only for HTML/XML family filetypes.
1087/// - `'` → `'` unless the character immediately before the cursor is
1088///   `[A-Za-z]` (prose apostrophe guard — "don't" stays "don't"), AND the
1089///   same triple-quote guard as `"` / `` ` ``.
1090fn autopair_close_for(
1091    ch: char,
1092    filetype: &str,
1093    prev_char: Option<char>,
1094    prev2_char: Option<char>,
1095) -> Option<char> {
1096    // Triple-quote guard — applies to ", `, and ' (the three quote chars
1097    // that get same-char pairing). When the previous two characters are
1098    // both this same quote, treat the third keystroke as a bare insert so
1099    // the user lands on `` ``` `` / `"""` / `'''` without a stray fourth
1100    // quote dangling after the cursor.
1101    let is_triple_quote_third =
1102        matches!(ch, '"' | '`' | '\'') && prev_char == Some(ch) && prev2_char == Some(ch);
1103
1104    match ch {
1105        '(' => Some(')'),
1106        '[' => Some(']'),
1107        '{' => Some('}'),
1108        '"' => {
1109            if is_triple_quote_third {
1110                None
1111            } else {
1112                Some('"')
1113            }
1114        }
1115        '`' => {
1116            if is_triple_quote_third {
1117                None
1118            } else {
1119                Some('`')
1120            }
1121        }
1122        '<' => {
1123            if is_html_filetype(filetype) {
1124                Some('>')
1125            } else {
1126                None
1127            }
1128        }
1129        '\'' => {
1130            if is_triple_quote_third {
1131                return None;
1132            }
1133            // Prose guard: skip pairing when the previous char is a letter
1134            // (covers "don't", "it's", etc.).
1135            if prev_char.map(|c| c.is_ascii_alphabetic()).unwrap_or(false) {
1136                None
1137            } else {
1138                Some('\'')
1139            }
1140        }
1141        _ => None,
1142    }
1143}
1144
1145/// Detect a markdown / doc-comment code-fence opener on the current line.
1146///
1147/// Returns `Some(fence)` (the backtick run that should be used as the
1148/// closing fence) when:
1149/// - The cursor is at the end of the visible line (`cursor_col` equals the
1150///   line's char count).
1151/// - The line, after leading whitespace, begins with 3+ backticks followed
1152///   by a non-empty language tag matching `[A-Za-z0-9_+-]+` and nothing
1153///   else (no trailing space, no extra text).
1154///
1155/// The language tag requirement is deliberate: a bare ` ``` ` could be
1156/// either an opener OR a closer, and we don't track fence parity here.
1157/// Requiring a tag means we only fire when the user is clearly opening a
1158/// fence (` ```rust `, ` ```ts `, etc.).
1159fn detect_code_fence_opener(line: &str, cursor_col: usize) -> Option<String> {
1160    if cursor_col != line.chars().count() {
1161        return None;
1162    }
1163    let trimmed = line.trim_start();
1164    let backtick_run = trimmed.chars().take_while(|c| *c == '`').count();
1165    if backtick_run < 3 {
1166        return None;
1167    }
1168    let rest = &trimmed[backtick_run..];
1169    if rest.is_empty() {
1170        return None;
1171    }
1172    let all_lang_chars = rest
1173        .chars()
1174        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '+' || c == '-');
1175    if !all_lang_chars {
1176        return None;
1177    }
1178    Some("`".repeat(backtick_run))
1179}
1180
1181/// Filetypes that get HTML/XML-family treatment (`<` pairing + tag autoclose).
1182fn is_html_filetype(ft: &str) -> bool {
1183    matches!(
1184        ft,
1185        "html" | "xml" | "svg" | "jsx" | "tsx" | "vue" | "svelte"
1186    )
1187}
1188
1189// ── Paired-tag auto-rename (issue #182) ────────────────────────────────────
1190//
1191// When the user edits the name of an HTML/XML opening tag (e.g. `ci<` to
1192// change-inner the tag name, type a new name, then `<Esc>`), the matching
1193// closing tag should rename automatically so the pair stays in sync.
1194// Same on the close side: edit `</X>` → its opener gets renamed.
1195//
1196// Trigger: leave_insert_to_normal_bridge calls sync_paired_tag_on_exit, which
1197// inspects the cursor's current position. If the cursor sits inside a tag
1198// name and the paired tag has a different name, rewrite the paired tag.
1199//
1200// Pairing uses a stack-based scan so nested same-name tags
1201// (`<div><div></div></div>`) pair correctly.
1202
1203/// Tag kind detected at a cursor position.
1204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1205enum TagKind {
1206    Open,
1207    Close,
1208}
1209
1210/// A single tag instance located in the buffer.
1211#[derive(Debug, Clone, PartialEq, Eq)]
1212struct TagSpan {
1213    kind: TagKind,
1214    name: String,
1215    /// Row index in the buffer.
1216    row: usize,
1217    /// Char-column range of the tag NAME (excluding `<`, `</`, attributes, `>`).
1218    name_start_col: usize,
1219    name_end_col: usize,
1220}
1221
1222/// Detect the tag containing `(row, col)` in `line`. Returns the tag kind
1223/// (Open / Close), its name, and the char-column range of that name.
1224/// Returns `None` when the cursor is not inside a tag-name region.
1225fn detect_tag_at_cursor(line: &str, row: usize, col: usize) -> Option<TagSpan> {
1226    let chars: Vec<char> = line.chars().collect();
1227    // Find the nearest `<` at or before the cursor column.
1228    let mut lt = None;
1229    let mut i = col.min(chars.len());
1230    while i > 0 {
1231        i -= 1;
1232        let c = chars[i];
1233        if c == '<' {
1234            lt = Some(i);
1235            break;
1236        }
1237        // Bail if we cross a `>` (we're outside any open tag).
1238        if c == '>' {
1239            return None;
1240        }
1241    }
1242    let lt = lt?;
1243    // Detect close tag (`</`) vs open (`<`).
1244    let (kind, name_start) = if chars.get(lt + 1) == Some(&'/') {
1245        (TagKind::Close, lt + 2)
1246    } else {
1247        (TagKind::Open, lt + 1)
1248    };
1249    // First char of the name must be a letter.
1250    let first = chars.get(name_start)?;
1251    if !first.is_ascii_alphabetic() {
1252        return None;
1253    }
1254    // Tag name = [A-Za-z][A-Za-z0-9-]*
1255    let mut name_end = name_start;
1256    while name_end < chars.len()
1257        && (chars[name_end].is_ascii_alphanumeric() || chars[name_end] == '-')
1258    {
1259        name_end += 1;
1260    }
1261    // Cursor must be inside the name range (inclusive of both ends so that
1262    // landing right after the name still resolves — vim Insert leaves the
1263    // cursor one past the last typed char).
1264    if col < name_start || col > name_end {
1265        return None;
1266    }
1267    let name: String = chars[name_start..name_end].iter().collect();
1268    Some(TagSpan {
1269        kind,
1270        name,
1271        row,
1272        name_start_col: name_start,
1273        name_end_col: name_end,
1274    })
1275}
1276
1277/// Scan the buffer to find the structural partner of `anchor` using a
1278/// depth counter. Names are intentionally NOT compared during the scan —
1279/// the anchor is the source of truth and the partner inherits its name.
1280/// Otherwise an in-flight rename (the whole point of this feature) would
1281/// look like a malformed pair and bail.
1282///
1283/// Forward scan from an opener: opens increment depth, closes decrement
1284/// depth. The close that brings depth back to zero is the partner.
1285/// Backward scan from a closer is symmetric (closes increment, opens
1286/// decrement).
1287///
1288/// Returns `None` when the buffer end is reached before depth hits zero
1289/// (orphan tag or malformed input).
1290fn find_matching_tag(buffer: &hjkl_buffer::Buffer, anchor: &TagSpan) -> Option<TagSpan> {
1291    let row_count = buffer.row_count();
1292    let scan_forward = anchor.kind == TagKind::Open;
1293    let row_iter: Box<dyn Iterator<Item = usize>> = if scan_forward {
1294        Box::new(anchor.row..row_count)
1295    } else {
1296        Box::new((0..=anchor.row).rev())
1297    };
1298    let push_kind = if scan_forward {
1299        TagKind::Open
1300    } else {
1301        TagKind::Close
1302    };
1303    let mut depth: usize = 1;
1304
1305    for r in row_iter {
1306        let line = buf_line(buffer, r)?;
1307        let chars: Vec<char> = line.chars().collect();
1308        let tags = scan_line_tags(&chars, r);
1309        let tags_iter: Box<dyn Iterator<Item = TagSpan>> = if scan_forward {
1310            Box::new(tags.into_iter())
1311        } else {
1312            Box::new(tags.into_iter().rev())
1313        };
1314        for tag in tags_iter {
1315            // Skip the anchor itself when we walk over its line.
1316            if r == anchor.row
1317                && tag.name_start_col == anchor.name_start_col
1318                && tag.kind == anchor.kind
1319            {
1320                continue;
1321            }
1322            // On the anchor's own row, gate by direction relative to anchor
1323            // so the scan only inspects tags AFTER the anchor (forward) or
1324            // BEFORE the anchor (backward).
1325            if r == anchor.row {
1326                if scan_forward && tag.name_start_col < anchor.name_start_col {
1327                    continue;
1328                }
1329                if !scan_forward && tag.name_start_col > anchor.name_start_col {
1330                    continue;
1331                }
1332            }
1333            if tag.kind == push_kind {
1334                depth += 1;
1335            } else {
1336                depth -= 1;
1337                if depth == 0 {
1338                    return Some(tag);
1339                }
1340            }
1341        }
1342    }
1343    None
1344}
1345
1346/// Collect all tag opens / closes on a single line in left-to-right order.
1347/// Skips comments (`<!-- ... -->`) and self-closing tags (`<br />`), and
1348/// excludes void HTML elements that don't form a pair.
1349fn scan_line_tags(chars: &[char], row: usize) -> Vec<TagSpan> {
1350    let mut out = Vec::new();
1351    let n = chars.len();
1352    let mut i = 0;
1353    while i < n {
1354        if chars[i] != '<' {
1355            i += 1;
1356            continue;
1357        }
1358        // `<!--` comment — skip to `-->`.
1359        if chars[i..].starts_with(&['<', '!', '-', '-']) {
1360            let mut j = i + 4;
1361            while j + 2 < n && !(chars[j] == '-' && chars[j + 1] == '-' && chars[j + 2] == '>') {
1362                j += 1;
1363            }
1364            i = (j + 3).min(n);
1365            continue;
1366        }
1367        let (kind, name_start) = if chars.get(i + 1) == Some(&'/') {
1368            (TagKind::Close, i + 2)
1369        } else {
1370            (TagKind::Open, i + 1)
1371        };
1372        // Validate name start.
1373        if chars
1374            .get(name_start)
1375            .is_none_or(|c| !c.is_ascii_alphabetic())
1376        {
1377            i += 1;
1378            continue;
1379        }
1380        let mut name_end = name_start;
1381        while name_end < n && (chars[name_end].is_ascii_alphanumeric() || chars[name_end] == '-') {
1382            name_end += 1;
1383        }
1384        // Find the closing `>` to know whether this tag is self-closing.
1385        let mut k = name_end;
1386        let mut self_closing = false;
1387        while k < n {
1388            if chars[k] == '>' {
1389                if k > name_end && chars[k - 1] == '/' {
1390                    self_closing = true;
1391                }
1392                break;
1393            }
1394            k += 1;
1395        }
1396        if k >= n {
1397            // Unterminated tag on this line — bail.
1398            break;
1399        }
1400        let name: String = chars[name_start..name_end].iter().collect();
1401        // Skip self-closing and void elements (no pair).
1402        if !(self_closing || kind == TagKind::Open && is_void_element(&name)) {
1403            out.push(TagSpan {
1404                kind,
1405                name,
1406                row,
1407                name_start_col: name_start,
1408                name_end_col: name_end,
1409            });
1410        }
1411        i = k + 1;
1412    }
1413    out
1414}
1415
1416/// If the cursor sits inside an HTML/XML tag name AND the paired tag's name
1417/// differs, rewrite the paired tag's name to match. Called from
1418/// `leave_insert_to_normal_bridge` so the magical sync fires exactly when
1419/// the user finishes editing.
1420pub(crate) fn sync_paired_tag_on_exit<H: crate::types::Host>(
1421    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1422) {
1423    if !is_html_filetype(&ed.settings.filetype) {
1424        return;
1425    }
1426    let (row, col) = ed.cursor();
1427    let line = match buf_line(&ed.buffer, row) {
1428        Some(l) => l,
1429        None => return,
1430    };
1431    let anchor = match detect_tag_at_cursor(&line, row, col) {
1432        Some(t) => t,
1433        None => return,
1434    };
1435    let partner = match find_matching_tag(&ed.buffer, &anchor) {
1436        Some(t) => t,
1437        None => return,
1438    };
1439    if partner.name == anchor.name {
1440        return;
1441    }
1442    // Rewrite the partner's name range with the anchor's name.
1443    use hjkl_buffer::{Edit, MotionKind, Position};
1444    let start = Position::new(partner.row, partner.name_start_col);
1445    let end = Position::new(partner.row, partner.name_end_col);
1446    ed.mutate_edit(Edit::DeleteRange {
1447        start,
1448        end,
1449        kind: MotionKind::Char,
1450    });
1451    ed.mutate_edit(Edit::InsertStr {
1452        at: start,
1453        text: anchor.name.clone(),
1454    });
1455    // Restore the user's cursor — mutate_edit may have moved it during the
1456    // partner-side rewrite when the partner is on a row before the cursor.
1457    buf_set_cursor_rc(&mut ed.buffer, row, col);
1458    ed.push_buffer_cursor_to_textarea();
1459}
1460
1461/// Resolve the HTML/XML tag-name pair under the cursor for matchparen-style
1462/// highlight (#243). Returns `[(row, name_start_col, name_end_col); 2]` for
1463/// the tag under the cursor and its structural partner, or `None` when the
1464/// cursor is not on a tag name or the tag is unpaired. Char-column ranges
1465/// (display), consistent with `motions::matching_bracket_pos`.
1466pub fn matching_tag_pair(
1467    buffer: &hjkl_buffer::Buffer,
1468    row: usize,
1469    col: usize,
1470) -> Option<[(usize, usize, usize); 2]> {
1471    let line = buf_line(buffer, row)?;
1472    let anchor = detect_tag_at_cursor(&line, row, col)?;
1473    let partner = find_matching_tag(buffer, &anchor)?;
1474    Some([
1475        (anchor.row, anchor.name_start_col, anchor.name_end_col),
1476        (partner.row, partner.name_start_col, partner.name_end_col),
1477    ])
1478}
1479
1480/// Void HTML elements that must never get an auto-close tag.
1481fn is_void_element(tag: &str) -> bool {
1482    matches!(
1483        tag.to_ascii_lowercase().as_str(),
1484        "area"
1485            | "base"
1486            | "br"
1487            | "col"
1488            | "embed"
1489            | "hr"
1490            | "img"
1491            | "input"
1492            | "link"
1493            | "meta"
1494            | "param"
1495            | "source"
1496            | "track"
1497            | "wbr"
1498    )
1499}
1500
1501/// Scan backward from `col` (exclusive) in `line` for a `<tagname…` opener.
1502///
1503/// Returns `Some(tag_name)` when:
1504/// - An opening `<` is found
1505/// - The tag name matches `[A-Za-z][A-Za-z0-9-]*`
1506/// - The tag is not self-closing (does not end with `/` before `>`)
1507/// - The tag is not a void element
1508///
1509/// Returns `None` otherwise (no opener, self-closing, void, or malformed).
1510fn scan_tag_opener(line: &str, col: usize) -> Option<String> {
1511    // col is where `>` was just inserted (the char is already in the line).
1512    // We look at the slice BEFORE the `>`.
1513    let before = if col > 0 { &line[..col] } else { return None };
1514
1515    // Walk backward to find the matching `<`.
1516    let lt_pos = before.rfind('<')?;
1517    let inner = &before[lt_pos + 1..]; // e.g. "div class=\"foo\""
1518
1519    // A `!` opener is a comment/doctype — skip.
1520    if inner.starts_with('!') {
1521        return None;
1522    }
1523    // Self-closing if the last non-space char before `>` was `/`.
1524    if inner.trim_end().ends_with('/') {
1525        return None;
1526    }
1527
1528    // Extract tag name: first token of `inner`.
1529    let tag: String = inner
1530        .chars()
1531        .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
1532        .collect();
1533    if tag.is_empty() {
1534        return None;
1535    }
1536    // First char must be a letter.
1537    if !tag
1538        .chars()
1539        .next()
1540        .map(|c| c.is_ascii_alphabetic())
1541        .unwrap_or(false)
1542    {
1543        return None;
1544    }
1545    if is_void_element(&tag) {
1546        return None;
1547    }
1548    Some(tag)
1549}
1550
1551/// Insert a single character at the cursor. Handles replace-mode overstrike
1552/// (when `InsertSession::reason` is `Replace`) and smart-indent dedent of
1553/// closing brackets (}/)]/). Also handles autopair insertion and skip-over.
1554/// Returns `true`.
1555pub(crate) fn insert_char_bridge<H: crate::types::Host>(
1556    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1557    ch: char,
1558) -> bool {
1559    use hjkl_buffer::{Edit, MotionKind, Position};
1560    ed.sync_buffer_content_from_textarea();
1561    let in_replace = matches!(
1562        ed.vim.insert_session.as_ref().map(|s| &s.reason),
1563        Some(InsertReason::Replace)
1564    );
1565
1566    // ── Abbreviation expansion (insert mode, non-replace) ────────────────────
1567    // A non-keyword char typed in insert mode can trigger expansion.
1568    // We check BEFORE inserting the character; if an abbrev matches, we delete
1569    // the lhs and insert the rhs, then continue to insert `ch` as normal.
1570    // `<C-v>` (literal-insert) must bypass this — callers that want literal
1571    // insertion should NOT call this bridge; they use insert_char_literal.
1572    if !in_replace && !ed.vim.abbrevs.is_empty() {
1573        let iskeyword = ed.settings.iskeyword.clone();
1574        if !is_keyword_char(ch, &iskeyword) {
1575            // Only non-keyword trigger chars fire abbreviation expansion.
1576            check_and_apply_abbrev(ed, AbbrevTrigger::NonKeyword(ch));
1577            // (we do NOT return early; continue to insert `ch` below)
1578        }
1579    }
1580    // ── Word-boundary undo break (Word granularity only; no-op for vim) ────────
1581    // Must fire after abbreviation expansion (the expansion may have changed the
1582    // cursor position) but before any actual buffer mutation so the snapshot
1583    // captures the pre-char state.
1584    maybe_word_undo_break(ed, ch);
1585
1586    // Read cursor (after any abbreviation expansion that may have changed the buffer).
1587    let cursor = buf_cursor_pos(&ed.buffer);
1588    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
1589
1590    // ── Skip-over: if the typed char matches the top of the pending-closes
1591    // stack AND the char currently under the cursor IS that close char,
1592    // pop the stack and advance the cursor instead of inserting.
1593    //
1594    // We check the actual char in the buffer (not a stored col) so that
1595    // characters typed between the pair don't invalidate the skip — the
1596    // close char shifts right as the user types inside, but the buffer
1597    // char check always finds it correctly.
1598    if !in_replace
1599        && !ed.vim.pending_closes.is_empty()
1600        && let Some(&(pr, _pc, pch)) = ed.vim.pending_closes.last()
1601        && ch == pch
1602        && cursor.row == pr
1603    {
1604        let char_at_cursor =
1605            buf_line(&ed.buffer, cursor.row).and_then(|l| l.chars().nth(cursor.col));
1606        if char_at_cursor == Some(ch) {
1607            ed.vim.pending_closes.pop();
1608            // For `>` skip-over in HTML/XML: also run tag autoclose.
1609            let filetype = ed.settings.filetype.clone();
1610            let autoclose_tag = ed.settings.autoclose_tag;
1611            if ch == '>' && autoclose_tag && is_html_filetype(&filetype) {
1612                // Skip past the `>` that was auto-inserted.
1613                let new_col = cursor.col + 1;
1614                buf_set_cursor_rc(&mut ed.buffer, cursor.row, new_col);
1615                // Now check for tag autoclose on the line up to new_col.
1616                // `new_col.saturating_sub(1)` is a char index; convert to a
1617                // byte offset before slicing in scan_tag_opener.
1618                if let Some(line) = buf_line(&ed.buffer, cursor.row) {
1619                    let char_col = new_col.saturating_sub(1);
1620                    let byte_col = line
1621                        .char_indices()
1622                        .nth(char_col)
1623                        .map(|(b, _)| b)
1624                        .unwrap_or(line.len());
1625                    if let Some(tag) = scan_tag_opener(&line, byte_col) {
1626                        let close_tag = format!("</{tag}>");
1627                        let insert_pos = Position::new(cursor.row, new_col);
1628                        ed.mutate_edit(Edit::InsertStr {
1629                            at: insert_pos,
1630                            text: close_tag,
1631                        });
1632                        // Cursor stays at new_col (between > and </tag>).
1633                        buf_set_cursor_rc(&mut ed.buffer, cursor.row, new_col);
1634                    }
1635                }
1636            } else {
1637                buf_set_cursor_rc(&mut ed.buffer, cursor.row, cursor.col + 1);
1638            }
1639            ed.push_buffer_cursor_to_textarea();
1640            return true;
1641        }
1642    }
1643
1644    if in_replace && cursor.col < line_chars {
1645        // Replace mode: clear pending closes (edit outside the pair).
1646        ed.vim.pending_closes.clear();
1647        ed.mutate_edit(Edit::DeleteRange {
1648            start: cursor,
1649            end: Position::new(cursor.row, cursor.col + 1),
1650            kind: MotionKind::Char,
1651        });
1652        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
1653    } else if !try_dedent_close_bracket(ed, cursor, ch) {
1654        // Normal insert. Check autopair first.
1655        let autopair = ed.settings.autopair;
1656        let filetype = ed.settings.filetype.clone();
1657        let autoclose_tag = ed.settings.autoclose_tag;
1658
1659        let (prev_char, prev2_char) = {
1660            let line = buf_line(&ed.buffer, cursor.row).unwrap_or_default();
1661            let chars: Vec<char> = line.chars().collect();
1662            let p1 = if cursor.col > 0 {
1663                chars.get(cursor.col - 1).copied()
1664            } else {
1665                None
1666            };
1667            let p2 = if cursor.col > 1 {
1668                chars.get(cursor.col - 2).copied()
1669            } else {
1670                None
1671            };
1672            (p1, p2)
1673        };
1674
1675        if autopair {
1676            if let Some(close) = autopair_close_for(ch, &filetype, prev_char, prev2_char) {
1677                // Insert open char.
1678                ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
1679                // Insert close char immediately after the open char.
1680                // After inserting open at cursor, buffer cursor is at cursor.col+1.
1681                let after = Position::new(cursor.row, cursor.col + 1);
1682                ed.mutate_edit(Edit::InsertChar {
1683                    at: after,
1684                    ch: close,
1685                });
1686                // After inserting close, buffer cursor is at cursor.col+2.
1687                // We want cursor between open and close: cursor.col+1.
1688                let between_col = cursor.col + 1;
1689                buf_set_cursor_rc(&mut ed.buffer, cursor.row, between_col);
1690                // Record the close char for skip-over. We store the row and
1691                // the close char; col is not tracked precisely because chars
1692                // typed inside the pair shift the close right. The skip-over
1693                // logic checks the actual buffer char at cursor instead.
1694                ed.vim.pending_closes.push((cursor.row, between_col, close));
1695                ed.push_buffer_cursor_to_textarea();
1696                return true;
1697            }
1698
1699            // Tag autoclose: `>` in HTML/XML family (no prior `<` pair).
1700            // This fires when autopair did NOT match `>` (e.g. `>` was
1701            // typed directly, not via a skip-over of an auto-inserted `>`).
1702            if ch == '>' && autoclose_tag && is_html_filetype(&filetype) {
1703                ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
1704                let new_col = cursor.col + 1;
1705                // scan_tag_opener looks at the line up to (new_col-1), i.e.
1706                // the char just inserted is at index new_col-1.
1707                // `new_col.saturating_sub(1)` is a char index; convert to a
1708                // byte offset before slicing in scan_tag_opener.
1709                if let Some(line) = buf_line(&ed.buffer, cursor.row) {
1710                    let char_col = new_col.saturating_sub(1);
1711                    let byte_col = line
1712                        .char_indices()
1713                        .nth(char_col)
1714                        .map(|(b, _)| b)
1715                        .unwrap_or(line.len());
1716                    if let Some(tag) = scan_tag_opener(&line, byte_col) {
1717                        let close_tag = format!("</{tag}>");
1718                        let insert_pos = Position::new(cursor.row, new_col);
1719                        ed.mutate_edit(Edit::InsertStr {
1720                            at: insert_pos,
1721                            text: close_tag,
1722                        });
1723                        // Cursor stays at new_col (between `>` and `</tag>`).
1724                        buf_set_cursor_rc(&mut ed.buffer, cursor.row, new_col);
1725                    }
1726                }
1727                ed.push_buffer_cursor_to_textarea();
1728                return true;
1729            }
1730        }
1731
1732        // Plain insert — do not clear the pending-closes stack here.
1733        // The stack is cleared on cursor motion or mode change (Esc).
1734        // Clearing here would prevent skip-over from firing after the
1735        // user types content inside an auto-paired bracket.
1736        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
1737    }
1738    ed.push_buffer_cursor_to_textarea();
1739    true
1740}
1741
1742/// Insert a newline at the cursor, applying autoindent / smartindent and
1743/// optionally continuing a line comment when `formatoptions` has `r`.
1744/// Also handles open-pair-newline: Enter between `{|}` / `(|)` / `[|]`
1745/// produces an indented block with the close on its own line.
1746/// Returns `true`.
1747pub(crate) fn insert_newline_bridge<H: crate::types::Host>(
1748    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1749) -> bool {
1750    use hjkl_buffer::Edit;
1751    ed.sync_buffer_content_from_textarea();
1752
1753    // ── Abbreviation expansion on CR ─────────────────────────────────────────
1754    // CR triggers expansion for full-id / end-id / non-id abbreviations.
1755    // We expand BEFORE the newline is inserted; CR is then inserted as normal.
1756    if !ed.vim.abbrevs.is_empty() {
1757        check_and_apply_abbrev(ed, AbbrevTrigger::Cr);
1758    }
1759
1760    // ── Word-boundary undo break for newline (Word granularity only) ────────────
1761    // Newline always starts a new undo unit in Word mode. Fire after
1762    // abbreviation expansion but before any buffer mutation.
1763    maybe_word_undo_break(ed, '\n');
1764
1765    let cursor = buf_cursor_pos(&ed.buffer);
1766    let prev_line = buf_line(&ed.buffer, cursor.row)
1767        .unwrap_or_default()
1768        .to_string();
1769
1770    // Open-pair-newline: if autopair is on and the cursor is between a
1771    // matching open/close bracket pair, split into two newlines so the
1772    // close ends up on its own dedented line.
1773    if ed.settings.autopair && !ed.vim.pending_closes.is_empty() {
1774        // Check: char before cursor is an open bracket AND char at cursor
1775        // is the matching close bracket (from our pending-closes stack).
1776        let prev_char = if cursor.col > 0 {
1777            prev_line.chars().nth(cursor.col - 1)
1778        } else {
1779            None
1780        };
1781        let next_char = prev_line.chars().nth(cursor.col);
1782        let is_open_pair = matches!(
1783            (prev_char, next_char),
1784            (Some('{'), Some('}')) | (Some('('), Some(')')) | (Some('['), Some(']'))
1785        );
1786        if is_open_pair {
1787            // The pending-closes stack refers to the close char at cursor.col.
1788            // We clear it because the newline expansion moves the close.
1789            ed.vim.pending_closes.clear();
1790            // Compute indents: inner gets one extra unit, close gets base.
1791            let base_indent: String = prev_line
1792                .chars()
1793                .take_while(|c| *c == ' ' || *c == '\t')
1794                .collect();
1795            let inner_indent = if ed.settings.expandtab {
1796                let unit = if ed.settings.softtabstop > 0 {
1797                    ed.settings.softtabstop
1798                } else {
1799                    ed.settings.shiftwidth
1800                };
1801                format!("{base_indent}{}", " ".repeat(unit))
1802            } else {
1803                format!("{base_indent}\t")
1804            };
1805            // Insert: \n<inner_indent>\n<base_indent>
1806            // Then cursor lands after the first \n (inside the block).
1807            let text = format!("\n{inner_indent}\n{base_indent}");
1808            ed.mutate_edit(Edit::InsertStr { at: cursor, text });
1809            // Move cursor to end of first new line (inner_indent line).
1810            let new_row = cursor.row + 1;
1811            let new_col = inner_indent.len();
1812            buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
1813            ed.push_buffer_cursor_to_textarea();
1814            return true;
1815        }
1816    }
1817
1818    // Code-fence expansion: line content is ` ``` ` (3+ backticks) followed
1819    // by a non-empty language tag, cursor sits at end of line → insert the
1820    // matching closing fence on the line below and park the cursor on a
1821    // blank middle line. Matches the open-pair-newline shape but for
1822    // markdown / doc-comment code blocks. Gated on a language tag because
1823    // a bare ` ``` ` could just as easily be a closing fence — we'd need
1824    // full document parity tracking to handle that safely, which v1
1825    // doesn't have.
1826    if ed.settings.autopair
1827        && let Some(fence) = detect_code_fence_opener(&prev_line, cursor.col)
1828    {
1829        ed.vim.pending_closes.clear();
1830        let base_indent: String = prev_line
1831            .chars()
1832            .take_while(|c| *c == ' ' || *c == '\t')
1833            .collect();
1834        let text = format!("\n{base_indent}\n{base_indent}{fence}");
1835        ed.mutate_edit(Edit::InsertStr { at: cursor, text });
1836        let new_row = cursor.row + 1;
1837        let new_col = base_indent.chars().count();
1838        buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
1839        ed.push_buffer_cursor_to_textarea();
1840        return true;
1841    }
1842
1843    // formatoptions `r`: continue comment on Enter in insert mode.
1844    let comment_cont = if ed.settings.formatoptions.contains('r') {
1845        continue_comment(&ed.buffer, &ed.settings, cursor.row)
1846    } else {
1847        None
1848    };
1849
1850    // Any Enter clears the pending-closes stack (cursor moved off the pair).
1851    ed.vim.pending_closes.clear();
1852
1853    let text = if let Some(cont) = comment_cont {
1854        // Comment continuation overrides autoindent: the indent is already
1855        // baked into the continuation prefix.
1856        format!("\n{cont}")
1857    } else {
1858        let indent = compute_enter_indent(&ed.settings, &prev_line);
1859        format!("\n{indent}")
1860    };
1861    ed.mutate_edit(Edit::InsertStr { at: cursor, text });
1862    ed.push_buffer_cursor_to_textarea();
1863    true
1864}
1865
1866/// Insert a tab character (or spaces up to the next softtabstop boundary when
1867/// `expandtab` is set). Returns `true`.
1868pub(crate) fn insert_tab_bridge<H: crate::types::Host>(
1869    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1870) -> bool {
1871    use hjkl_buffer::Edit;
1872    ed.sync_buffer_content_from_textarea();
1873    let cursor = buf_cursor_pos(&ed.buffer);
1874    if ed.settings.expandtab {
1875        let sts = ed.settings.softtabstop;
1876        let n = if sts > 0 {
1877            sts - (cursor.col % sts)
1878        } else {
1879            ed.settings.tabstop.max(1)
1880        };
1881        ed.mutate_edit(Edit::InsertStr {
1882            at: cursor,
1883            text: " ".repeat(n),
1884        });
1885    } else {
1886        ed.mutate_edit(Edit::InsertChar {
1887            at: cursor,
1888            ch: '\t',
1889        });
1890    }
1891    ed.push_buffer_cursor_to_textarea();
1892    true
1893}
1894
1895/// Delete the character before the cursor (vim Backspace / `^H`). With
1896/// `softtabstop` active, deletes the entire soft-tab run at an aligned
1897/// boundary. Joins with the previous line when at column 0.
1898///
1899/// **Comment-continuation backspace**: when the current line's entire content
1900/// is the auto-inserted comment prefix (e.g. `// ` with nothing after it),
1901/// a single Backspace removes the whole prefix in one stroke — vim parity.
1902///
1903/// Returns `true` when something was deleted, `false` at the very start of the
1904/// buffer.
1905pub(crate) fn insert_backspace_bridge<H: crate::types::Host>(
1906    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1907) -> bool {
1908    use hjkl_buffer::{Edit, MotionKind, Position};
1909    ed.sync_buffer_content_from_textarea();
1910    let cursor = buf_cursor_pos(&ed.buffer);
1911
1912    // Comment-continuation backspace: if the line is just the prefix (with no
1913    // user content after it), delete the whole prefix in one stroke.
1914    if cursor.col > 0 {
1915        let line = buf_line(&ed.buffer, cursor.row).unwrap_or_default();
1916        if let Some((indent, prefix)) = detect_comment_on_line(&ed.settings.filetype, &line) {
1917            let full_prefix = format!("{indent}{prefix}");
1918            // The cursor must be at the end of (or within) the prefix with no
1919            // additional content after — i.e. the line equals the prefix exactly.
1920            let line_trimmed = line.trim_end_matches(' ');
1921            let prefix_trimmed = full_prefix.trim_end_matches(' ');
1922            if line_trimmed == prefix_trimmed && cursor.col == full_prefix.chars().count() {
1923                // Delete everything from col 0 to cursor.
1924                ed.mutate_edit(Edit::DeleteRange {
1925                    start: Position::new(cursor.row, 0),
1926                    end: cursor,
1927                    kind: MotionKind::Char,
1928                });
1929                ed.push_buffer_cursor_to_textarea();
1930                return true;
1931            }
1932        }
1933    }
1934
1935    let sts = ed.settings.softtabstop;
1936    if sts > 0 && cursor.col >= sts && cursor.col.is_multiple_of(sts) {
1937        let line = buf_line(&ed.buffer, cursor.row).unwrap_or_default();
1938        let chars: Vec<char> = line.chars().collect();
1939        let run_start = cursor.col - sts;
1940        if (run_start..cursor.col).all(|i| chars.get(i).copied() == Some(' ')) {
1941            ed.mutate_edit(Edit::DeleteRange {
1942                start: Position::new(cursor.row, run_start),
1943                end: cursor,
1944                kind: MotionKind::Char,
1945            });
1946            ed.push_buffer_cursor_to_textarea();
1947            return true;
1948        }
1949    }
1950    let result = if cursor.col > 0 {
1951        ed.mutate_edit(Edit::DeleteRange {
1952            start: Position::new(cursor.row, cursor.col - 1),
1953            end: cursor,
1954            kind: MotionKind::Char,
1955        });
1956        true
1957    } else if cursor.row > 0 {
1958        let prev_row = cursor.row - 1;
1959        let prev_chars = buf_line_chars(&ed.buffer, prev_row);
1960        ed.mutate_edit(Edit::JoinLines {
1961            row: prev_row,
1962            count: 1,
1963            with_space: false,
1964        });
1965        buf_set_cursor_rc(&mut ed.buffer, prev_row, prev_chars);
1966        true
1967    } else {
1968        false
1969    };
1970    ed.push_buffer_cursor_to_textarea();
1971    result
1972}
1973
1974/// Delete the character under the cursor (vim `Delete`). Joins with the
1975/// next line when at end-of-line. Returns `true` when something was deleted.
1976pub(crate) fn insert_delete_bridge<H: crate::types::Host>(
1977    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1978) -> bool {
1979    use hjkl_buffer::{Edit, MotionKind, Position};
1980    ed.sync_buffer_content_from_textarea();
1981    let cursor = buf_cursor_pos(&ed.buffer);
1982    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
1983    let result = if cursor.col < line_chars {
1984        ed.mutate_edit(Edit::DeleteRange {
1985            start: cursor,
1986            end: Position::new(cursor.row, cursor.col + 1),
1987            kind: MotionKind::Char,
1988        });
1989        buf_set_cursor_pos(&mut ed.buffer, cursor);
1990        true
1991    } else if cursor.row + 1 < buf_row_count(&ed.buffer) {
1992        ed.mutate_edit(Edit::JoinLines {
1993            row: cursor.row,
1994            count: 1,
1995            with_space: false,
1996        });
1997        buf_set_cursor_pos(&mut ed.buffer, cursor);
1998        true
1999    } else {
2000        false
2001    };
2002    ed.push_buffer_cursor_to_textarea();
2003    result
2004}
2005
2006/// Move the cursor one step in `dir`, breaking the undo group per
2007/// `undo_break_on_motion`. Clears the autopair pending-closes stack (cursor
2008/// moved off the pair). Returns `false` (no mutation).
2009pub(crate) fn insert_arrow_bridge<H: crate::types::Host>(
2010    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2011    dir: InsertDir,
2012) -> bool {
2013    ed.sync_buffer_content_from_textarea();
2014    ed.vim.pending_closes.clear();
2015    match dir {
2016        InsertDir::Left => {
2017            crate::motions::move_left(&mut ed.buffer, 1);
2018        }
2019        InsertDir::Right => {
2020            crate::motions::move_right_to_end(&mut ed.buffer, 1);
2021        }
2022        InsertDir::Up => {
2023            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2024            crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
2025        }
2026        InsertDir::Down => {
2027            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2028            crate::motions::move_down(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
2029        }
2030    }
2031    break_undo_group_in_insert(ed);
2032    ed.push_buffer_cursor_to_textarea();
2033    false
2034}
2035
2036/// Move the cursor to the start of the current line, breaking the undo group.
2037/// Clears the autopair pending-closes stack. Returns `false` (no mutation).
2038pub(crate) fn insert_home_bridge<H: crate::types::Host>(
2039    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2040) -> bool {
2041    ed.sync_buffer_content_from_textarea();
2042    ed.vim.pending_closes.clear();
2043    crate::motions::move_line_start(&mut ed.buffer);
2044    break_undo_group_in_insert(ed);
2045    ed.push_buffer_cursor_to_textarea();
2046    false
2047}
2048
2049/// Move the cursor to the end of the current line, breaking the undo group.
2050/// Clears the autopair pending-closes stack. Returns `false` (no mutation).
2051pub(crate) fn insert_end_bridge<H: crate::types::Host>(
2052    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2053) -> bool {
2054    ed.sync_buffer_content_from_textarea();
2055    ed.vim.pending_closes.clear();
2056    crate::motions::move_line_end(&mut ed.buffer);
2057    break_undo_group_in_insert(ed);
2058    ed.push_buffer_cursor_to_textarea();
2059    false
2060}
2061
2062/// Scroll up one full viewport height, moving the cursor with it.
2063/// Breaks the undo group. Returns `false` (no mutation).
2064pub(crate) fn insert_pageup_bridge<H: crate::types::Host>(
2065    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2066    viewport_h: u16,
2067) -> bool {
2068    let rows = viewport_h.saturating_sub(2).max(1) as isize;
2069    scroll_cursor_rows(ed, -rows);
2070    false
2071}
2072
2073/// Scroll down one full viewport height, moving the cursor with it.
2074/// Breaks the undo group. Returns `false` (no mutation).
2075pub(crate) fn insert_pagedown_bridge<H: crate::types::Host>(
2076    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2077    viewport_h: u16,
2078) -> bool {
2079    let rows = viewport_h.saturating_sub(2).max(1) as isize;
2080    scroll_cursor_rows(ed, rows);
2081    false
2082}
2083
2084/// Delete from the cursor back to the start of the previous word (`Ctrl-W`).
2085/// At col 0, joins with the previous line (vim semantics). Returns `true`
2086/// when something was deleted.
2087pub(crate) fn insert_ctrl_w_bridge<H: crate::types::Host>(
2088    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2089) -> bool {
2090    use hjkl_buffer::{Edit, MotionKind};
2091    ed.sync_buffer_content_from_textarea();
2092    let cursor = buf_cursor_pos(&ed.buffer);
2093    if cursor.row == 0 && cursor.col == 0 {
2094        return true;
2095    }
2096    crate::motions::move_word_back(&mut ed.buffer, false, 1, &ed.settings.iskeyword);
2097    let word_start = buf_cursor_pos(&ed.buffer);
2098    if word_start == cursor {
2099        return true;
2100    }
2101    buf_set_cursor_pos(&mut ed.buffer, cursor);
2102    ed.mutate_edit(Edit::DeleteRange {
2103        start: word_start,
2104        end: cursor,
2105        kind: MotionKind::Char,
2106    });
2107    ed.push_buffer_cursor_to_textarea();
2108    true
2109}
2110
2111/// Delete from the cursor back to the start of the current line (`Ctrl-U`).
2112/// No-op when already at column 0. Returns `true` when something was deleted.
2113pub(crate) fn insert_ctrl_u_bridge<H: crate::types::Host>(
2114    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2115) -> bool {
2116    use hjkl_buffer::{Edit, MotionKind, Position};
2117    ed.sync_buffer_content_from_textarea();
2118    let cursor = buf_cursor_pos(&ed.buffer);
2119    if cursor.col > 0 {
2120        ed.mutate_edit(Edit::DeleteRange {
2121            start: Position::new(cursor.row, 0),
2122            end: cursor,
2123            kind: MotionKind::Char,
2124        });
2125        ed.push_buffer_cursor_to_textarea();
2126    }
2127    true
2128}
2129
2130/// Delete one character backwards (`Ctrl-H`) — alias for Backspace in insert
2131/// mode. Joins with the previous line when at col 0. Returns `true` when
2132/// something was deleted.
2133pub(crate) fn insert_ctrl_h_bridge<H: crate::types::Host>(
2134    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2135) -> bool {
2136    use hjkl_buffer::{Edit, MotionKind, Position};
2137    ed.sync_buffer_content_from_textarea();
2138    let cursor = buf_cursor_pos(&ed.buffer);
2139    if cursor.col > 0 {
2140        ed.mutate_edit(Edit::DeleteRange {
2141            start: Position::new(cursor.row, cursor.col - 1),
2142            end: cursor,
2143            kind: MotionKind::Char,
2144        });
2145    } else if cursor.row > 0 {
2146        let prev_row = cursor.row - 1;
2147        let prev_chars = buf_line_chars(&ed.buffer, prev_row);
2148        ed.mutate_edit(Edit::JoinLines {
2149            row: prev_row,
2150            count: 1,
2151            with_space: false,
2152        });
2153        buf_set_cursor_rc(&mut ed.buffer, prev_row, prev_chars);
2154    }
2155    ed.push_buffer_cursor_to_textarea();
2156    true
2157}
2158
2159/// Indent the current line by one `shiftwidth` and shift the cursor right by
2160/// the same amount (`Ctrl-T`). Returns `true`.
2161pub(crate) fn insert_ctrl_t_bridge<H: crate::types::Host>(
2162    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2163) -> bool {
2164    let (row, col) = ed.cursor();
2165    let sw = ed.settings().shiftwidth;
2166    indent_rows(ed, row, row, 1);
2167    ed.jump_cursor(row, col + sw);
2168    true
2169}
2170
2171/// Outdent the current line by up to one `shiftwidth` and shift the cursor
2172/// left by the amount stripped (`Ctrl-D`). Returns `true`.
2173pub(crate) fn insert_ctrl_d_bridge<H: crate::types::Host>(
2174    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2175) -> bool {
2176    let (row, col) = ed.cursor();
2177    let before_len = buf_line_bytes(&ed.buffer, row);
2178    outdent_rows(ed, row, row, 1);
2179    let after_len = buf_line_bytes(&ed.buffer, row);
2180    let stripped = before_len.saturating_sub(after_len);
2181    let new_col = col.saturating_sub(stripped);
2182    ed.jump_cursor(row, new_col);
2183    true
2184}
2185
2186/// Enter "one-shot normal" mode (`Ctrl-O`): suspend insert for the next
2187/// complete normal-mode command, then return to insert. Returns `false`
2188/// (no buffer mutation — only mode state changes).
2189pub(crate) fn insert_ctrl_o_bridge<H: crate::types::Host>(
2190    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2191) -> bool {
2192    ed.vim.one_shot_normal = true;
2193    ed.vim.mode = Mode::Normal;
2194    // Phase 6.3: keep current_mode in sync for callers that bypass step().
2195    ed.vim.current_mode = crate::VimMode::Normal;
2196    false
2197}
2198
2199/// Arm the register-paste selector (`Ctrl-R`): the next typed character
2200/// names the register whose text will be inserted inline. Returns `false`
2201/// (no buffer mutation yet — mutation happens when the register char arrives).
2202pub(crate) fn insert_ctrl_r_bridge<H: crate::types::Host>(
2203    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2204) -> bool {
2205    ed.vim.insert_pending_register = true;
2206    false
2207}
2208
2209/// Paste the contents of `reg` at the cursor (the body of `Ctrl-R {reg}`).
2210/// Unknown or empty registers are a no-op. Returns `true` when text was
2211/// inserted.
2212pub(crate) fn insert_paste_register_bridge<H: crate::types::Host>(
2213    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2214    reg: char,
2215) -> bool {
2216    insert_register_text(ed, reg);
2217    // insert_register_text already calls mark_content_dirty internally;
2218    // return true to signal that the session row window should be widened.
2219    true
2220}
2221
2222/// Exit insert mode to Normal: finish the insert session, step the cursor one
2223/// cell left (vim convention), record the `gi` target, and update the sticky
2224/// column. Clears the autopair pending-closes stack. Returns `true` (always
2225/// consumed — even if no buffer mutation, the mode change itself is a
2226/// meaningful step).
2227pub(crate) fn leave_insert_to_normal_bridge<H: crate::types::Host>(
2228    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2229) -> bool {
2230    ed.vim.pending_closes.clear();
2231
2232    // ── Abbreviation expansion on Esc ────────────────────────────────────────
2233    // Esc triggers expansion for all abbreviation types.
2234    if !ed.vim.abbrevs.is_empty() {
2235        check_and_apply_abbrev(ed, AbbrevTrigger::Esc);
2236    }
2237
2238    finish_insert_session(ed);
2239    // Paired-tag auto-rename (issue #182). Must run BEFORE the cursor moves
2240    // left (the move-left is vim's "leave-insert cursor adjustment"; the
2241    // sync needs the post-insert cursor position to detect the tag name).
2242    sync_paired_tag_on_exit(ed);
2243    ed.vim.mode = Mode::Normal;
2244    // Phase 6.3: keep current_mode in sync for callers that bypass step().
2245    ed.vim.current_mode = crate::VimMode::Normal;
2246    let col = ed.cursor().1;
2247    ed.vim.last_insert_pos = Some(ed.cursor());
2248    if col > 0 {
2249        crate::motions::move_left(&mut ed.buffer, 1);
2250        ed.push_buffer_cursor_to_textarea();
2251    }
2252    ed.sticky_col = Some(ed.cursor().1);
2253    true
2254}
2255
2256// ─── Phase 6.2: normal-mode primitive bridges ──────────────────────────────
2257
2258// ── Insert-mode entry bridges ──────────────────────────────────────────────
2259
2260/// `i` — begin Insert at the cursor. `count` is stored in the session for
2261/// insert-exit replay. Returns `true`.
2262pub(crate) fn enter_insert_i_bridge<H: crate::types::Host>(
2263    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2264    count: usize,
2265) {
2266    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::I));
2267}
2268
2269/// `I` — move to first non-blank then begin Insert. `count` stored for replay.
2270pub(crate) fn enter_insert_shift_i_bridge<H: crate::types::Host>(
2271    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2272    count: usize,
2273) {
2274    move_first_non_whitespace(ed);
2275    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftI));
2276}
2277
2278/// `a` — advance past the cursor char then begin Insert. `count` for replay.
2279pub(crate) fn enter_insert_a_bridge<H: crate::types::Host>(
2280    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2281    count: usize,
2282) {
2283    crate::motions::move_right_to_end(&mut ed.buffer, 1);
2284    ed.push_buffer_cursor_to_textarea();
2285    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::A));
2286}
2287
2288/// `A` — move to end-of-line then begin Insert. `count` for replay.
2289pub(crate) fn enter_insert_shift_a_bridge<H: crate::types::Host>(
2290    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2291    count: usize,
2292) {
2293    crate::motions::move_line_end(&mut ed.buffer);
2294    crate::motions::move_right_to_end(&mut ed.buffer, 1);
2295    ed.push_buffer_cursor_to_textarea();
2296    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftA));
2297}
2298
2299/// `o` — open a new line below the cursor and begin Insert.
2300/// When `formatoptions` has `o` and the current line is a comment, the
2301/// continuation prefix is inserted automatically.
2302pub(crate) fn open_line_below_bridge<H: crate::types::Host>(
2303    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2304    count: usize,
2305) {
2306    use hjkl_buffer::{Edit, Position};
2307    ed.push_undo();
2308    begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: false });
2309    ed.sync_buffer_content_from_textarea();
2310    let row = buf_cursor_pos(&ed.buffer).row;
2311    let line_chars = buf_line_chars(&ed.buffer, row);
2312    let prev_line = buf_line(&ed.buffer, row).unwrap_or_default();
2313
2314    // formatoptions `o`: continue comment on open-below.
2315    let comment_cont = if ed.settings.formatoptions.contains('o') {
2316        continue_comment(&ed.buffer, &ed.settings, row)
2317    } else {
2318        None
2319    };
2320
2321    let suffix = if let Some(cont) = comment_cont {
2322        format!("\n{cont}")
2323    } else {
2324        let indent = compute_enter_indent(&ed.settings, &prev_line);
2325        format!("\n{indent}")
2326    };
2327    ed.mutate_edit(Edit::InsertStr {
2328        at: Position::new(row, line_chars),
2329        text: suffix,
2330    });
2331    ed.push_buffer_cursor_to_textarea();
2332}
2333
2334/// `O` — open a new line above the cursor and begin Insert.
2335/// When `formatoptions` has `o` and the current line is a comment, the
2336/// continuation prefix is inserted automatically on the new line above.
2337pub(crate) fn open_line_above_bridge<H: crate::types::Host>(
2338    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2339    count: usize,
2340) {
2341    use hjkl_buffer::{Edit, Position};
2342    ed.push_undo();
2343    begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: true });
2344    ed.sync_buffer_content_from_textarea();
2345    let row = buf_cursor_pos(&ed.buffer).row;
2346
2347    // formatoptions `o`: continue comment on open-above (current line drives).
2348    let comment_cont = if ed.settings.formatoptions.contains('o') {
2349        continue_comment(&ed.buffer, &ed.settings, row)
2350    } else {
2351        None
2352    };
2353
2354    // `new_line_content` is the text of the new line (without the trailing `\n`).
2355    // Used to position the cursor at the end of that content after the move.
2356    let (insert_text, new_line_content) = if let Some(cont) = comment_cont {
2357        let content = cont.clone();
2358        (format!("{cont}\n"), content)
2359    } else {
2360        // vim `O` autoindent copies the CURRENT line's indent (the line the
2361        // cursor sits on, which becomes the line *below* the new one), NOT the
2362        // line above. Using the line above wrongly inherits a deeper child's
2363        // indent when the cursor is on a shallower line (e.g. explorer tree:
2364        // `O` on a dir whose preceding row is its own nested child).
2365        let cur = buf_line(&ed.buffer, row).unwrap_or_default();
2366        let indent = compute_enter_indent(&ed.settings, &cur);
2367        let content = indent.clone();
2368        (format!("{indent}\n"), content)
2369    };
2370    ed.mutate_edit(Edit::InsertStr {
2371        at: Position::new(row, 0),
2372        text: insert_text,
2373    });
2374    let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2375    crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
2376    let new_row = buf_cursor_pos(&ed.buffer).row;
2377    buf_set_cursor_rc(&mut ed.buffer, new_row, new_line_content.chars().count());
2378    ed.push_buffer_cursor_to_textarea();
2379}
2380
2381/// `R` — enter Replace mode (overstrike). `count` stored for replay.
2382pub(crate) fn enter_replace_mode_bridge<H: crate::types::Host>(
2383    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2384    count: usize,
2385) {
2386    // Guard delegated to begin_insert which already checks modifiable/Blame.
2387    begin_insert(ed, count.max(1), InsertReason::Replace);
2388}
2389
2390// ── Char / line ops ────────────────────────────────────────────────────────
2391
2392/// `x` — delete `count` chars forward from the cursor, writing to the unnamed
2393/// register. Records `LastChange::CharDel` for dot-repeat.
2394pub(crate) fn delete_char_forward_bridge<H: crate::types::Host>(
2395    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2396    count: usize,
2397) {
2398    do_char_delete(ed, true, count.max(1));
2399    if !ed.vim.replaying {
2400        ed.vim.last_change = Some(LastChange::CharDel {
2401            forward: true,
2402            count: count.max(1),
2403        });
2404    }
2405}
2406
2407/// `X` — delete `count` chars backward from the cursor, writing to the unnamed
2408/// register. Records `LastChange::CharDel` for dot-repeat.
2409pub(crate) fn delete_char_backward_bridge<H: crate::types::Host>(
2410    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2411    count: usize,
2412) {
2413    do_char_delete(ed, false, count.max(1));
2414    if !ed.vim.replaying {
2415        ed.vim.last_change = Some(LastChange::CharDel {
2416            forward: false,
2417            count: count.max(1),
2418        });
2419    }
2420}
2421
2422/// `s` — substitute `count` chars (delete then enter Insert). Equivalent to
2423/// `cl`. Records `LastChange::OpMotion` for dot-repeat.
2424pub(crate) fn substitute_char_bridge<H: crate::types::Host>(
2425    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2426    count: usize,
2427) {
2428    use hjkl_buffer::{Edit, MotionKind, Position};
2429    ed.push_undo();
2430    ed.sync_buffer_content_from_textarea();
2431    for _ in 0..count.max(1) {
2432        let cursor = buf_cursor_pos(&ed.buffer);
2433        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
2434        if cursor.col >= line_chars {
2435            break;
2436        }
2437        ed.mutate_edit(Edit::DeleteRange {
2438            start: cursor,
2439            end: Position::new(cursor.row, cursor.col + 1),
2440            kind: MotionKind::Char,
2441        });
2442    }
2443    ed.push_buffer_cursor_to_textarea();
2444    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
2445    if !ed.vim.replaying {
2446        ed.vim.last_change = Some(LastChange::OpMotion {
2447            op: Operator::Change,
2448            motion: Motion::Right,
2449            count: count.max(1),
2450            inserted: None,
2451        });
2452    }
2453}
2454
2455/// `S` — substitute the whole line (delete line contents then enter Insert).
2456/// Equivalent to `cc`. Records `LastChange::LineOp` for dot-repeat.
2457pub(crate) fn substitute_line_bridge<H: crate::types::Host>(
2458    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2459    count: usize,
2460) {
2461    execute_line_op(ed, Operator::Change, count.max(1));
2462    if !ed.vim.replaying {
2463        ed.vim.last_change = Some(LastChange::LineOp {
2464            op: Operator::Change,
2465            count: count.max(1),
2466            inserted: None,
2467        });
2468    }
2469}
2470
2471/// `D` — delete from the cursor to end-of-line, writing to the unnamed
2472/// register. Cursor parks on the new last char. Records for dot-repeat.
2473pub(crate) fn delete_to_eol_bridge<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2474    ed.push_undo();
2475    delete_to_eol(ed);
2476    crate::motions::move_left(&mut ed.buffer, 1);
2477    ed.push_buffer_cursor_to_textarea();
2478    if !ed.vim.replaying {
2479        ed.vim.last_change = Some(LastChange::DeleteToEol { inserted: None });
2480    }
2481}
2482
2483/// `C` — change from the cursor to end-of-line (delete then enter Insert).
2484/// Equivalent to `c$`. Shares the delete path with `D`.
2485pub(crate) fn change_to_eol_bridge<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2486    ed.push_undo();
2487    delete_to_eol(ed);
2488    begin_insert_noundo(ed, 1, InsertReason::DeleteToEol);
2489}
2490
2491/// `Y` — yank from the cursor to end-of-line (same as `y$` in Vim 8 default).
2492pub(crate) fn yank_to_eol_bridge<H: crate::types::Host>(
2493    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2494    count: usize,
2495) {
2496    apply_op_with_motion(ed, Operator::Yank, &Motion::LineEnd, count.max(1));
2497}
2498
2499/// `J` — join `count` lines (default 2) onto the current one, inserting a
2500/// single space between each pair (vim semantics). Records for dot-repeat.
2501pub(crate) fn join_line_bridge<H: crate::types::Host>(
2502    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2503    count: usize,
2504) {
2505    // vim `[count]J` joins `count` lines together — i.e. `count - 1` joins.
2506    // Bare `J` (and `1J`) join the current line with the one below (1 join).
2507    let joins = count.max(2) - 1;
2508    for _ in 0..joins {
2509        ed.push_undo();
2510        if !join_line(ed) {
2511            break;
2512        }
2513    }
2514    if !ed.vim.replaying {
2515        ed.vim.last_change = Some(LastChange::JoinLine { count: joins });
2516    }
2517}
2518
2519/// `~` — toggle the case of `count` chars from the cursor, advancing right.
2520/// Records `LastChange::ToggleCase` for dot-repeat.
2521pub(crate) fn toggle_case_at_cursor_bridge<H: crate::types::Host>(
2522    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2523    count: usize,
2524) {
2525    for _ in 0..count.max(1) {
2526        ed.push_undo();
2527        if !toggle_case_at_cursor(ed) {
2528            break;
2529        }
2530    }
2531    if !ed.vim.replaying {
2532        ed.vim.last_change = Some(LastChange::ToggleCase {
2533            count: count.max(1),
2534        });
2535    }
2536}
2537
2538/// `p` — paste the unnamed register (or `"reg` register) after the cursor.
2539/// Linewise yanks open a new line below; charwise pastes inline.
2540/// Records `LastChange::Paste` for dot-repeat.
2541pub(crate) fn paste_after_bridge<H: crate::types::Host>(
2542    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2543    count: usize,
2544) {
2545    paste_bridge(ed, false, count, false, false);
2546}
2547
2548/// `P` — paste the unnamed register (or `"reg` register) before the cursor.
2549/// Linewise yanks open a new line above; charwise pastes inline.
2550/// Records `LastChange::Paste` for dot-repeat.
2551pub(crate) fn paste_before_bridge<H: crate::types::Host>(
2552    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2553    count: usize,
2554) {
2555    paste_bridge(ed, true, count, false, false);
2556}
2557
2558/// Shared paste entry for `p`/`P`, `gp`/`gP` (`cursor_after`), and
2559/// `]p`/`[p` (`reindent`). Records `LastChange::Paste` for dot-repeat.
2560pub(crate) fn paste_bridge<H: crate::types::Host>(
2561    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2562    before: bool,
2563    count: usize,
2564    cursor_after: bool,
2565    reindent: bool,
2566) {
2567    do_paste(ed, before, count.max(1), cursor_after, reindent);
2568    if !ed.vim.replaying {
2569        ed.vim.last_change = Some(LastChange::Paste {
2570            before,
2571            count: count.max(1),
2572            cursor_after,
2573            reindent,
2574        });
2575    }
2576}
2577
2578// ── Jump bridges ───────────────────────────────────────────────────────────
2579
2580/// `<C-o>` — jump back `count` entries in the jumplist, saving the current
2581/// position on the forward stack so `<C-i>` can return.
2582pub(crate) fn jump_back_bridge<H: crate::types::Host>(
2583    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2584    count: usize,
2585) {
2586    for _ in 0..count.max(1) {
2587        if !jump_back(ed) {
2588            break;
2589        }
2590    }
2591}
2592
2593/// `<C-i>` / `Tab` — redo `count` jumps on the forward stack, saving the
2594/// current position on the backward stack.
2595pub(crate) fn jump_forward_bridge<H: crate::types::Host>(
2596    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2597    count: usize,
2598) {
2599    for _ in 0..count.max(1) {
2600        if !jump_forward(ed) {
2601            break;
2602        }
2603    }
2604}
2605
2606// ── Scroll bridges ─────────────────────────────────────────────────────────
2607
2608/// `<C-f>` / `<C-b>` — scroll the cursor by one full viewport height
2609/// (`h - 2` rows to preserve two-line overlap). `count` multiplies.
2610pub(crate) fn scroll_full_page_bridge<H: crate::types::Host>(
2611    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2612    dir: ScrollDir,
2613    count: usize,
2614) {
2615    ed.vim.scroll_anim_hint = true;
2616    let rows = viewport_full_rows(ed, count) as isize;
2617    match dir {
2618        ScrollDir::Down => scroll_cursor_rows(ed, rows),
2619        ScrollDir::Up => scroll_cursor_rows(ed, -rows),
2620    }
2621}
2622
2623/// `<C-d>` / `<C-u>` — scroll the cursor by half the viewport height.
2624/// `count` multiplies.
2625pub(crate) fn scroll_half_page_bridge<H: crate::types::Host>(
2626    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2627    dir: ScrollDir,
2628    count: usize,
2629) {
2630    ed.vim.scroll_anim_hint = true;
2631    let rows = viewport_half_rows(ed, count) as isize;
2632    match dir {
2633        ScrollDir::Down => scroll_cursor_rows(ed, rows),
2634        ScrollDir::Up => scroll_cursor_rows(ed, -rows),
2635    }
2636}
2637
2638/// `<C-e>` / `<C-y>` — scroll the viewport `count` lines without moving the
2639/// cursor (cursor is clamped to the new visible region if it would go
2640/// off-screen). `<C-e>` scrolls down; `<C-y>` scrolls up.
2641pub(crate) fn scroll_line_bridge<H: crate::types::Host>(
2642    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2643    dir: ScrollDir,
2644    count: usize,
2645) {
2646    let n = count.max(1);
2647    let total = buf_row_count(&ed.buffer);
2648    let last = total.saturating_sub(1);
2649    let h = ed.viewport_height_value() as usize;
2650    let vp = ed.host().viewport();
2651    let cur_top = vp.top_row;
2652    let new_top = match dir {
2653        ScrollDir::Down => (cur_top + n).min(last),
2654        ScrollDir::Up => cur_top.saturating_sub(n),
2655    };
2656    ed.set_viewport_top(new_top);
2657    // Clamp cursor to stay within the new visible region.
2658    let (row, col) = ed.cursor();
2659    let bot = (new_top + h).saturating_sub(1).min(last);
2660    let clamped = row.max(new_top).min(bot);
2661    if clamped != row {
2662        buf_set_cursor_rc(&mut ed.buffer, clamped, col);
2663        ed.push_buffer_cursor_to_textarea();
2664    }
2665}
2666
2667// ── Search bridges ─────────────────────────────────────────────────────────
2668
2669/// `n` / `N` — repeat the last search `count` times. `forward = true` means
2670/// repeat in the original search direction; `false` inverts it (like `N`).
2671pub(crate) fn search_repeat_bridge<H: crate::types::Host>(
2672    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2673    forward: bool,
2674    count: usize,
2675) {
2676    if let Some(pattern) = ed.vim.last_search.clone() {
2677        ed.push_search_pattern(&pattern);
2678    }
2679    if ed.search_state().pattern.is_none() {
2680        return;
2681    }
2682    let go_forward = ed.vim.last_search_forward == forward;
2683    for _ in 0..count.max(1) {
2684        if go_forward {
2685            ed.search_advance_forward(true);
2686        } else {
2687            ed.search_advance_backward(true);
2688        }
2689    }
2690    ed.push_buffer_cursor_to_textarea();
2691}
2692
2693/// `*` / `#` / `g*` / `g#` — search for the word under the cursor.
2694/// `forward` picks search direction; `whole_word` wraps in `\b...\b`.
2695/// `count` repeats the advance.
2696pub(crate) fn word_search_bridge<H: crate::types::Host>(
2697    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2698    forward: bool,
2699    whole_word: bool,
2700    count: usize,
2701) {
2702    word_at_cursor_search(ed, forward, whole_word, count.max(1));
2703}
2704
2705// ── Undo / redo confirmation wrappers (already public on Editor) ───────────
2706
2707/// `u` bridge — identical to `do_undo`; retained for Phase 6.6b audit.
2708/// The FSM now calls `ed.undo()` directly (Phase 6.6a).
2709#[allow(dead_code)]
2710#[inline]
2711pub(crate) fn do_undo_bridge<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2712    do_undo(ed);
2713}
2714
2715// ─── Phase 6.3: visual-mode primitive bridges ──────────────────────────────
2716//
2717// Each `pub(crate)` free function is the extractable body of one visual-mode
2718// transition. These bridges set `vim.mode` directly AND write `current_mode`
2719// so that `Editor::vim_mode()` can read from the stable field without going
2720// through `public_mode()`.
2721//
2722// Pattern identical to Phase 6.1 / 6.2:
2723//   - Bridge fn is `pub(crate) fn *_bridge<H: Host>(ed, …)` in this file.
2724//   - Public wrapper is `pub fn *(&mut self, …)` in `editor.rs` with rustdoc.
2725
2726/// Drop the `Blame` view overlay whenever the input mode is no longer
2727/// `Normal`. BLAME is a Normal-only read-only view; entering Insert/Visual/etc.
2728/// (by keyboard, mouse drag, or programmatic transition) implicitly leaves it.
2729/// Called from every mode-transition funnel so the FSM is the single source of
2730/// truth — the host never has to police this.
2731#[inline]
2732pub fn drop_blame_if_left_normal<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2733    if ed.vim.current_mode != crate::VimMode::Normal {
2734        ed.view = crate::ViewMode::Normal;
2735    }
2736}
2737
2738/// Helper — set both the FSM-internal `mode` and the stable `current_mode`
2739/// field in one call. Every Phase 6.3 bridge that changes mode calls this so
2740/// `vim_mode()` stays correct without going through the FSM's `step()` loop.
2741#[inline]
2742pub(crate) fn set_vim_mode_bridge<H: crate::types::Host>(
2743    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2744    mode: Mode,
2745) {
2746    ed.vim.mode = mode;
2747    ed.vim.current_mode = ed.vim.public_mode();
2748    drop_blame_if_left_normal(ed);
2749}
2750
2751/// `v` from Normal — enter charwise Visual mode. Anchors at the current
2752/// cursor position; the cursor IS the live end of the selection.
2753pub(crate) fn enter_visual_char_bridge<H: crate::types::Host>(
2754    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2755) {
2756    let cur = ed.cursor();
2757    ed.vim.visual_anchor = cur;
2758    set_vim_mode_bridge(ed, Mode::Visual);
2759}
2760
2761/// `V` from Normal — enter linewise Visual mode. Anchors the whole line
2762/// containing the current cursor; `o` still swaps the anchor row.
2763pub(crate) fn enter_visual_line_bridge<H: crate::types::Host>(
2764    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2765) {
2766    let (row, _) = ed.cursor();
2767    ed.vim.visual_line_anchor = row;
2768    set_vim_mode_bridge(ed, Mode::VisualLine);
2769}
2770
2771/// `<C-v>` from Normal — enter Visual-block mode. Anchors at the current
2772/// cursor; `block_vcol` is seeded from the cursor column so h/l navigation
2773/// preserves the desired virtual column.
2774pub(crate) fn enter_visual_block_bridge<H: crate::types::Host>(
2775    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2776) {
2777    let cur = ed.cursor();
2778    ed.vim.block_anchor = cur;
2779    ed.vim.block_vcol = cur.1;
2780    set_vim_mode_bridge(ed, Mode::VisualBlock);
2781}
2782
2783/// Esc from any visual mode — set `<` / `>` marks (per `:h v_:`), stash the
2784/// selection for `gv` re-entry, and return to Normal. Replicates the
2785/// `pre_visual_snapshot` logic in `step()` so callers outside the FSM get
2786/// identical behaviour.
2787pub(crate) fn exit_visual_to_normal_bridge<H: crate::types::Host>(
2788    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2789) {
2790    // Build the same snapshot that `step()` captures at pre-step time.
2791    let snap: Option<LastVisual> = match ed.vim.mode {
2792        Mode::Visual => Some(LastVisual {
2793            mode: Mode::Visual,
2794            anchor: ed.vim.visual_anchor,
2795            cursor: ed.cursor(),
2796            block_vcol: 0,
2797        }),
2798        Mode::VisualLine => Some(LastVisual {
2799            mode: Mode::VisualLine,
2800            anchor: (ed.vim.visual_line_anchor, 0),
2801            cursor: ed.cursor(),
2802            block_vcol: 0,
2803        }),
2804        Mode::VisualBlock => Some(LastVisual {
2805            mode: Mode::VisualBlock,
2806            anchor: ed.vim.block_anchor,
2807            cursor: ed.cursor(),
2808            block_vcol: ed.vim.block_vcol,
2809        }),
2810        _ => None,
2811    };
2812    // Transition to Normal first (matches FSM order).
2813    ed.vim.pending = Pending::None;
2814    ed.vim.count = 0;
2815    ed.vim.insert_session = None;
2816    set_vim_mode_bridge(ed, Mode::Normal);
2817    // Set `<` / `>` marks and stash `last_visual` — mirrors the post-step
2818    // logic in `step()` that fires when a visual → non-visual transition
2819    // is detected.
2820    if let Some(snap) = snap {
2821        let (lo, hi) = match snap.mode {
2822            Mode::Visual => {
2823                if snap.anchor <= snap.cursor {
2824                    (snap.anchor, snap.cursor)
2825                } else {
2826                    (snap.cursor, snap.anchor)
2827                }
2828            }
2829            Mode::VisualLine => {
2830                let r_lo = snap.anchor.0.min(snap.cursor.0);
2831                let r_hi = snap.anchor.0.max(snap.cursor.0);
2832                let vl_rope = ed.buffer().rope();
2833                let r_hi_clamped = r_hi.min(vl_rope.len_lines().saturating_sub(1));
2834                let last_col = hjkl_buffer::rope_line_str(&vl_rope, r_hi_clamped)
2835                    .chars()
2836                    .count()
2837                    .saturating_sub(1);
2838                ((r_lo, 0), (r_hi, last_col))
2839            }
2840            Mode::VisualBlock => {
2841                let (r1, c1) = snap.anchor;
2842                let (r2, c2) = snap.cursor;
2843                ((r1.min(r2), c1.min(c2)), (r1.max(r2), c1.max(c2)))
2844            }
2845            _ => {
2846                if snap.anchor <= snap.cursor {
2847                    (snap.anchor, snap.cursor)
2848                } else {
2849                    (snap.cursor, snap.anchor)
2850                }
2851            }
2852        };
2853        ed.set_mark('<', lo);
2854        ed.set_mark('>', hi);
2855        ed.vim.last_visual = Some(snap);
2856    }
2857}
2858
2859/// `o` in Visual / VisualLine / VisualBlock — swap the cursor and anchor
2860/// without mutating the selection range. In charwise mode the cursor jumps
2861/// to the old anchor and the anchor takes the old cursor. In linewise mode
2862/// the anchor *row* swaps with the current cursor row. In block mode the
2863/// block corners swap.
2864pub(crate) fn visual_o_toggle_bridge<H: crate::types::Host>(
2865    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2866) {
2867    match ed.vim.mode {
2868        Mode::Visual => {
2869            let cur = ed.cursor();
2870            let anchor = ed.vim.visual_anchor;
2871            ed.vim.visual_anchor = cur;
2872            ed.jump_cursor(anchor.0, anchor.1);
2873        }
2874        Mode::VisualLine => {
2875            let cur_row = ed.cursor().0;
2876            let anchor_row = ed.vim.visual_line_anchor;
2877            ed.vim.visual_line_anchor = cur_row;
2878            ed.jump_cursor(anchor_row, 0);
2879        }
2880        Mode::VisualBlock => {
2881            let cur = ed.cursor();
2882            let anchor = ed.vim.block_anchor;
2883            ed.vim.block_anchor = cur;
2884            ed.vim.block_vcol = anchor.1;
2885            ed.jump_cursor(anchor.0, anchor.1);
2886        }
2887        _ => {}
2888    }
2889}
2890
2891/// `gv` — restore the last visual selection (mode + anchor + cursor).
2892/// No-op if no selection was ever stored. Mirrors the `gv` arm in
2893/// `handle_normal_g`.
2894pub(crate) fn reenter_last_visual_bridge<H: crate::types::Host>(
2895    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2896) {
2897    if let Some(snap) = ed.vim.last_visual {
2898        match snap.mode {
2899            Mode::Visual => {
2900                ed.vim.visual_anchor = snap.anchor;
2901                set_vim_mode_bridge(ed, Mode::Visual);
2902            }
2903            Mode::VisualLine => {
2904                ed.vim.visual_line_anchor = snap.anchor.0;
2905                set_vim_mode_bridge(ed, Mode::VisualLine);
2906            }
2907            Mode::VisualBlock => {
2908                ed.vim.block_anchor = snap.anchor;
2909                ed.vim.block_vcol = snap.block_vcol;
2910                set_vim_mode_bridge(ed, Mode::VisualBlock);
2911            }
2912            _ => {}
2913        }
2914        ed.jump_cursor(snap.cursor.0, snap.cursor.1);
2915    }
2916}
2917
2918/// Direct mode-transition entry point for external controllers (e.g.
2919/// hjkl-vim). Sets both the FSM-internal `mode` and the stable
2920/// `current_mode`. Use sparingly — prefer the semantic primitives
2921/// (`enter_visual_char_bridge`, `enter_insert_i_bridge`, …) which also
2922/// set up the required bookkeeping (anchors, sessions, …).
2923pub(crate) fn set_mode_bridge<H: crate::types::Host>(
2924    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2925    mode: crate::VimMode,
2926) {
2927    let internal = match mode {
2928        crate::VimMode::Normal => Mode::Normal,
2929        crate::VimMode::Insert => Mode::Insert,
2930        crate::VimMode::Visual => Mode::Visual,
2931        crate::VimMode::VisualLine => Mode::VisualLine,
2932        crate::VimMode::VisualBlock => Mode::VisualBlock,
2933    };
2934    ed.vim.mode = internal;
2935    ed.vim.current_mode = mode;
2936    drop_blame_if_left_normal(ed);
2937}
2938
2939// ─── Normal / Visual / Operator-pending dispatcher removed in Phase 6.6g.3 ──
2940//
2941// `step_normal` and all private dispatch helpers (handle_after_op,
2942// handle_after_g, handle_after_z, handle_normal_only, etc.) were deleted.
2943// The canonical FSM body lives in `hjkl-vim::normal`. Use
2944// `hjkl_vim::dispatch_input` as the entry point.
2945//
2946// DELETED FUNCTION SIGNATURE (for archaeology):
2947// pub(crate) fn step_normal<H: crate::types::Host>(ed: ..., input: Input) -> bool {
2948
2949/// `m{ch}` — public controller entry point. Validates `ch` (must be
2950/// alphanumeric to match vim's mark-name rules) and records the current
2951/// cursor position under that name. Promoted to the public surface in 0.6.7
2952/// so the hjkl-vim `PendingState::SetMark` reducer can dispatch
2953/// `EngineCmd::SetMark` without re-entering the engine FSM.
2954/// `handle_set_mark` delegates here to avoid logic duplication.
2955pub(crate) fn set_mark_at_cursor<H: crate::types::Host>(
2956    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2957    ch: char,
2958) {
2959    if ch.is_ascii_lowercase() {
2960        let pos = ed.cursor();
2961        ed.set_mark(ch, pos);
2962    } else if ch.is_ascii_uppercase() {
2963        let pos = ed.cursor();
2964        let bid = ed.current_buffer_id();
2965        ed.set_global_mark(ch, bid, pos);
2966        tracing::debug!(
2967            mark = ch as u32,
2968            buffer_id = bid,
2969            row = pos.0,
2970            col = pos.1,
2971            "global mark set"
2972        );
2973    }
2974    // Invalid chars silently no-op (mirrors handle_set_mark behaviour).
2975}
2976
2977/// `'<ch>` / `` `<ch> `` — public controller entry point for lowercase and
2978/// special marks. Validates `ch` against the set of legal mark names
2979/// (lowercase, special: `'`/`` ` ``/`.`/`[`/`]`/`<`/`>`), resolves the
2980/// target position, and jumps the cursor. `linewise = true` → row only, col
2981/// snaps to first non-blank; `linewise = false` → exact (row, col).
2982///
2983/// Uppercase marks are handled by [`try_goto_mark`] which can return a
2984/// `MarkJump::CrossBuffer` for cross-buffer jumps.
2985pub(crate) fn goto_mark<H: crate::types::Host>(
2986    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2987    ch: char,
2988    linewise: bool,
2989) {
2990    let target = match ch {
2991        'a'..='z' => ed.mark(ch),
2992        '\'' | '`' => ed.vim.jump_back.last().copied(),
2993        '.' => ed.last_edit_pos,
2994        '[' | ']' | '<' | '>' => ed.mark(ch),
2995        _ => None,
2996    };
2997    let Some((row, col)) = target else {
2998        return;
2999    };
3000    let pre = ed.cursor();
3001    let (r, c_clamped) = clamp_pos(ed, (row, col));
3002    if linewise {
3003        buf_set_cursor_rc(&mut ed.buffer, r, 0);
3004        ed.push_buffer_cursor_to_textarea();
3005        move_first_non_whitespace(ed);
3006    } else {
3007        buf_set_cursor_rc(&mut ed.buffer, r, c_clamped);
3008        ed.push_buffer_cursor_to_textarea();
3009    }
3010    if ed.cursor() != pre {
3011        ed.push_jump(pre);
3012    }
3013    ed.sticky_col = Some(ed.cursor().1);
3014}
3015
3016/// Unified mark-jump entry point that returns a [`crate::editor::MarkJump`]
3017/// so the app layer can decide whether to switch buffers.
3018///
3019/// - Uppercase marks (`'A'`–`'Z'`) look in `global_marks`. If the stored
3020///   `buffer_id` differs from `ed.current_buffer_id()`, returns
3021///   `CrossBuffer`. Same-buffer uppercase marks execute the jump normally.
3022/// - All other legal mark chars delegate to [`goto_mark`] and return
3023///   `SameBuffer`.
3024pub(crate) fn try_goto_mark<H: crate::types::Host>(
3025    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3026    ch: char,
3027    linewise: bool,
3028) -> crate::editor::MarkJump {
3029    use crate::editor::MarkJump;
3030    match ch {
3031        'A'..='Z' => {
3032            let Some((bid, row, col)) = ed.global_mark(ch) else {
3033                return MarkJump::Unset;
3034            };
3035            if bid != ed.current_buffer_id() {
3036                tracing::debug!(
3037                    mark = ch as u32,
3038                    buffer_id = bid,
3039                    row,
3040                    col,
3041                    "global mark cross-buffer jump"
3042                );
3043                return MarkJump::CrossBuffer {
3044                    buffer_id: bid,
3045                    row,
3046                    col,
3047                };
3048            }
3049            // Same buffer — execute the jump normally.
3050            let pre = ed.cursor();
3051            let (r, c_clamped) = clamp_pos(ed, (row, col));
3052            if linewise {
3053                buf_set_cursor_rc(&mut ed.buffer, r, 0);
3054                ed.push_buffer_cursor_to_textarea();
3055                move_first_non_whitespace(ed);
3056            } else {
3057                buf_set_cursor_rc(&mut ed.buffer, r, c_clamped);
3058                ed.push_buffer_cursor_to_textarea();
3059            }
3060            if ed.cursor() != pre {
3061                ed.push_jump(pre);
3062            }
3063            ed.sticky_col = Some(ed.cursor().1);
3064            MarkJump::SameBuffer
3065        }
3066        'a'..='z' | '\'' | '`' | '.' | '[' | ']' | '<' | '>' => {
3067            goto_mark(ed, ch, linewise);
3068            MarkJump::SameBuffer
3069        }
3070        _ => MarkJump::Unset,
3071    }
3072}
3073
3074/// `true` when `op` records a `last_change` entry for dot-repeat purposes.
3075/// Promoted to `pub` in Phase 6.6e so `hjkl-vim::normal` can use it without
3076/// duplicating the logic.
3077pub fn op_is_change(op: Operator) -> bool {
3078    matches!(op, Operator::Delete | Operator::Change)
3079}
3080
3081// ─── Jumplist (Ctrl-o / Ctrl-i) ────────────────────────────────────────────
3082
3083/// `Ctrl-o` — jump back to the most recent pre-jump position. Saves
3084/// the current cursor onto the forward stack so `Ctrl-i` can return.
3085/// Returns `false` when the back stack is empty so counted loops stop.
3086fn jump_back<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) -> bool {
3087    let Some(target) = ed.vim.jump_back.pop() else {
3088        return false;
3089    };
3090    let cur = ed.cursor();
3091    ed.vim.jump_fwd.push(cur);
3092    let (r, c) = clamp_pos(ed, target);
3093    ed.jump_cursor(r, c);
3094    ed.sticky_col = Some(c);
3095    true
3096}
3097
3098/// `Ctrl-i` / `Tab` — redo the last `Ctrl-o`. Saves the current cursor
3099/// onto the back stack.
3100/// Returns `false` when the forward stack is empty so counted loops stop.
3101fn jump_forward<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) -> bool {
3102    let Some(target) = ed.vim.jump_fwd.pop() else {
3103        return false;
3104    };
3105    let cur = ed.cursor();
3106    ed.vim.jump_back.push(cur);
3107    if ed.vim.jump_back.len() > JUMPLIST_MAX {
3108        ed.vim.jump_back.remove(0);
3109    }
3110    let (r, c) = clamp_pos(ed, target);
3111    ed.jump_cursor(r, c);
3112    ed.sticky_col = Some(c);
3113    true
3114}
3115
3116/// Clamp a stored `(row, col)` to the live buffer in case edits
3117/// shrunk the document between push and pop.
3118fn clamp_pos<H: crate::types::Host>(
3119    ed: &Editor<hjkl_buffer::Buffer, H>,
3120    pos: (usize, usize),
3121) -> (usize, usize) {
3122    let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
3123    let r = pos.0.min(last_row);
3124    let line_len = buf_line_chars(&ed.buffer, r);
3125    let c = pos.1.min(line_len.saturating_sub(1));
3126    (r, c)
3127}
3128
3129/// True for motions that vim treats as jumps (pushed onto the jumplist).
3130fn is_big_jump(motion: &Motion) -> bool {
3131    matches!(
3132        motion,
3133        Motion::FileTop
3134            | Motion::FileBottom
3135            | Motion::MatchBracket
3136            | Motion::WordAtCursor { .. }
3137            | Motion::SearchNext { .. }
3138            | Motion::ViewportTop
3139            | Motion::ViewportMiddle
3140            | Motion::ViewportBottom
3141    )
3142}
3143
3144// ─── Scroll helpers (Ctrl-d / Ctrl-u / Ctrl-f / Ctrl-b) ────────────────────
3145
3146/// Half-viewport row count, with a floor of 1 so tiny / un-rendered
3147/// viewports still step by a single row. `count` multiplies.
3148fn viewport_half_rows<H: crate::types::Host>(
3149    ed: &Editor<hjkl_buffer::Buffer, H>,
3150    count: usize,
3151) -> usize {
3152    let h = ed.viewport_height_value() as usize;
3153    (h / 2).max(1).saturating_mul(count.max(1))
3154}
3155
3156/// Full-viewport row count. Vim conventionally keeps 2 lines of overlap
3157/// between successive `Ctrl-f` pages; we approximate with `h - 2`.
3158fn viewport_full_rows<H: crate::types::Host>(
3159    ed: &Editor<hjkl_buffer::Buffer, H>,
3160    count: usize,
3161) -> usize {
3162    let h = ed.viewport_height_value() as usize;
3163    h.saturating_sub(2).max(1).saturating_mul(count.max(1))
3164}
3165
3166/// Move the cursor by `delta` rows (positive = down, negative = up),
3167/// clamp to the document, then land at the first non-blank on the new
3168/// row. The textarea viewport auto-scrolls to keep the cursor visible
3169/// when the cursor pushes off-screen.
3170fn scroll_cursor_rows<H: crate::types::Host>(
3171    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3172    delta: isize,
3173) {
3174    if delta == 0 {
3175        return;
3176    }
3177    ed.sync_buffer_content_from_textarea();
3178    let (row, _) = ed.cursor();
3179    let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
3180    let target = (row as isize + delta).max(0).min(last_row as isize) as usize;
3181    buf_set_cursor_rc(&mut ed.buffer, target, 0);
3182    crate::motions::move_first_non_blank(&mut ed.buffer);
3183    ed.push_buffer_cursor_to_textarea();
3184    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
3185}
3186
3187// ─── Motion parsing ────────────────────────────────────────────────────────
3188
3189/// Parse the first key of a normal/visual-mode motion. Returns `None` for
3190/// keys that don't start a motion (operator keys, command keys, etc.).
3191/// Promoted to `pub` in Phase 6.6e so `hjkl-vim::normal` can call it.
3192pub fn parse_motion(input: &Input) -> Option<Motion> {
3193    if input.ctrl {
3194        // `<C-h>` is vim's `<BS>` — a wrapping left motion. (The hjkl app
3195        // rebinds `<C-h>` to window-focus-left before it reaches the engine;
3196        // this keeps it correct for engine consumers that don't override it.)
3197        if input.key == Key::Char('h') {
3198            return Some(Motion::BackspaceBack);
3199        }
3200        return None;
3201    }
3202    match input.key {
3203        Key::Char('h') | Key::Left => Some(Motion::Left),
3204        Key::Char('l') | Key::Right => Some(Motion::Right),
3205        // `<Space>`/`<BS>` are vim's right/left motions that WRAP at line ends
3206        // (default `whichwrap=b,s`), unlike `l`/`h`/arrows which never wrap.
3207        // Operators (`d<Space>`/`d<BS>`) act on one char mid-line like `dl`/`dh`.
3208        Key::Char(' ') => Some(Motion::SpaceFwd),
3209        Key::Backspace => Some(Motion::BackspaceBack),
3210        Key::Char('j') | Key::Down => Some(Motion::Down),
3211        // `+` / `<CR>` — first non-blank of next line (linewise, count-aware).
3212        Key::Char('+') | Key::Enter => Some(Motion::FirstNonBlankNextLine),
3213        // `-` — first non-blank of previous line (linewise, count-aware).
3214        Key::Char('-') => Some(Motion::FirstNonBlankPrevLine),
3215        // `_` — first non-blank of current line, or count-1 lines down (linewise).
3216        Key::Char('_') => Some(Motion::FirstNonBlankLine),
3217        Key::Char('k') | Key::Up => Some(Motion::Up),
3218        Key::Char('w') => Some(Motion::WordFwd),
3219        Key::Char('W') => Some(Motion::BigWordFwd),
3220        Key::Char('b') => Some(Motion::WordBack),
3221        Key::Char('B') => Some(Motion::BigWordBack),
3222        Key::Char('e') => Some(Motion::WordEnd),
3223        Key::Char('E') => Some(Motion::BigWordEnd),
3224        Key::Char('0') | Key::Home => Some(Motion::LineStart),
3225        Key::Char('^') => Some(Motion::FirstNonBlank),
3226        Key::Char('$') | Key::End => Some(Motion::LineEnd),
3227        Key::Char('G') => Some(Motion::FileBottom),
3228        Key::Char('%') => Some(Motion::MatchBracket),
3229        Key::Char(';') => Some(Motion::FindRepeat { reverse: false }),
3230        Key::Char(',') => Some(Motion::FindRepeat { reverse: true }),
3231        Key::Char('*') => Some(Motion::WordAtCursor {
3232            forward: true,
3233            whole_word: true,
3234        }),
3235        Key::Char('#') => Some(Motion::WordAtCursor {
3236            forward: false,
3237            whole_word: true,
3238        }),
3239        Key::Char('n') => Some(Motion::SearchNext { reverse: false }),
3240        Key::Char('N') => Some(Motion::SearchNext { reverse: true }),
3241        Key::Char('H') => Some(Motion::ViewportTop),
3242        Key::Char('M') => Some(Motion::ViewportMiddle),
3243        Key::Char('L') => Some(Motion::ViewportBottom),
3244        Key::Char('{') => Some(Motion::ParagraphPrev),
3245        Key::Char('}') => Some(Motion::ParagraphNext),
3246        Key::Char('(') => Some(Motion::SentencePrev),
3247        Key::Char(')') => Some(Motion::SentenceNext),
3248        Key::Char('|') => Some(Motion::GotoColumn),
3249        _ => None,
3250    }
3251}
3252
3253// ─── Motion execution ──────────────────────────────────────────────────────
3254
3255pub(crate) fn execute_motion<H: crate::types::Host>(
3256    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3257    motion: Motion,
3258    count: usize,
3259) {
3260    let count = count.clamp(1, MAX_COUNT);
3261    // `;`/`,` smart fallback: if the last horizontal motion was a sneak
3262    // digraph, repeat via apply_sneak instead of find-char.
3263    if let Motion::FindRepeat { reverse } = motion
3264        && ed.vim.last_horizontal_motion == LastHorizontalMotion::Sneak
3265    {
3266        if let Some(((c1, c2), fwd)) = ed.vim.last_sneak {
3267            let effective_fwd = if reverse { !fwd } else { fwd };
3268            apply_sneak(ed, c1, c2, effective_fwd, count);
3269        }
3270        return;
3271    }
3272    // FindRepeat needs the stored direction. A `;`/`,` repeat of a `t`/`T`
3273    // find must skip an immediately-adjacent match (vim's repeat quirk); flag
3274    // it so the `Motion::Find` dispatch below passes `skip_adjacent`.
3275    let motion = match motion {
3276        Motion::FindRepeat { reverse } => match ed.vim.last_find {
3277            Some((ch, forward, till)) => {
3278                ed.vim.find_repeat_skip = true;
3279                Motion::Find {
3280                    ch,
3281                    forward: if reverse { !forward } else { forward },
3282                    till,
3283                }
3284            }
3285            None => return,
3286        },
3287        other => other,
3288    };
3289    let pre_pos = ed.cursor();
3290    let pre_col = pre_pos.1;
3291    apply_motion_cursor(ed, &motion, count);
3292    let post_pos = ed.cursor();
3293    if is_big_jump(&motion) && pre_pos != post_pos {
3294        ed.push_jump(pre_pos);
3295    }
3296    apply_sticky_col(ed, &motion, pre_col);
3297    // Phase 7b: keep the migration buffer's cursor + viewport in
3298    // lockstep with the textarea after every motion. Once 7c lands
3299    // (motions ported onto the buffer's API), this flips: the
3300    // buffer becomes authoritative and the textarea mirrors it.
3301    ed.sync_buffer_from_textarea();
3302}
3303
3304// ─── Keymap-layer motion controller ────────────────────────────────────────
3305
3306/// Wrapper around `execute_motion` that also syncs `block_vcol` when in
3307/// VisualBlock mode. The engine FSM's `step()` already does this (line ~2001);
3308/// the keymap path (`apply_motion_kind`) must do the same so VisualBlock h/l
3309/// extend the highlighted region correctly.
3310///
3311/// `update_block_vcol` is only a no-op for vertical / non-horizontal motions
3312/// (Up, Down, FileTop, FileBottom, Search), so passing every motion through is
3313/// safe — the function's own match arm handles the no-op case.
3314fn execute_motion_with_block_vcol<H: crate::types::Host>(
3315    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3316    motion: Motion,
3317    count: usize,
3318) {
3319    let motion_copy = motion.clone();
3320    execute_motion(ed, motion, count);
3321    if ed.vim.mode == Mode::VisualBlock {
3322        update_block_vcol(ed, &motion_copy);
3323    }
3324}
3325
3326/// Execute a `crate::MotionKind` cursor motion. Called by the host's
3327/// `Editor::apply_motion` controller method — the keymap dispatch path for
3328/// Phase 3a of kryptic-sh/hjkl#69.
3329///
3330/// Maps each variant to the same internal primitives used by the engine FSM
3331/// so cursor, sticky column, scroll, and sync semantics are identical.
3332///
3333/// # Visual-mode post-motion sync audit (2026-05-13)
3334///
3335/// After `execute_motion`, two things are conditional on visual mode:
3336///
3337/// 1. **VisualBlock `block_vcol` sync** — `update_block_vcol(ed, &motion)` is
3338///    called when `mode == Mode::VisualBlock`.  This is replicated here via
3339///    `execute_motion_with_block_vcol` for every motion variant below.
3340///
3341/// 2. **`last_find` update** — `Motion::Find` is dispatched through
3342///    `Pending::Find → apply_find_char` (in hjkl-vim), which writes `last_find`
3343///    itself.  A post-motion `last_find` write here would be dead code.  The keymap
3344///    path writes `last_find` in `apply_find_char` (called from
3345///    `Editor::find_char`), so no gap exists here.
3346///
3347/// No VisualLine-specific or Visual-specific post-motion work exists in the
3348/// FSM: anchors (`visual_anchor`, `visual_line_anchor`, `block_anchor`) are
3349/// only written on mode-entry or `o`-swap, never on motion.  The `<`/`>`
3350/// mark update in `step()` fires only on visual→normal transition, not after
3351/// each motion.  There are **no further sync gaps** beyond the `block_vcol`
3352/// fix already applied above.
3353pub(crate) fn apply_motion_kind<H: crate::types::Host>(
3354    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3355    kind: crate::MotionKind,
3356    count: usize,
3357) {
3358    let count = count.max(1);
3359    match kind {
3360        crate::MotionKind::CharLeft => {
3361            execute_motion_with_block_vcol(ed, Motion::Left, count);
3362        }
3363        crate::MotionKind::CharRight => {
3364            execute_motion_with_block_vcol(ed, Motion::Right, count);
3365        }
3366        crate::MotionKind::LineDown => {
3367            execute_motion_with_block_vcol(ed, Motion::Down, count);
3368        }
3369        crate::MotionKind::LineUp => {
3370            execute_motion_with_block_vcol(ed, Motion::Up, count);
3371        }
3372        crate::MotionKind::FirstNonBlankDown => {
3373            // `+`: move down `count` lines then land on first non-blank.
3374            // Not a big-jump (no jump-list entry), sticky col set to the
3375            // landed column (first non-blank). Mirrors scroll_cursor_rows
3376            // semantics but goes through the fold-aware buffer motion path.
3377            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3378            crate::motions::move_down(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
3379            crate::motions::move_first_non_blank(&mut ed.buffer);
3380            ed.push_buffer_cursor_to_textarea();
3381            ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
3382            ed.sync_buffer_from_textarea();
3383        }
3384        crate::MotionKind::FirstNonBlankUp => {
3385            // `-`: move up `count` lines then land on first non-blank.
3386            // Same pattern as FirstNonBlankDown, direction reversed.
3387            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3388            crate::motions::move_up(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
3389            crate::motions::move_first_non_blank(&mut ed.buffer);
3390            ed.push_buffer_cursor_to_textarea();
3391            ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
3392            ed.sync_buffer_from_textarea();
3393        }
3394        crate::MotionKind::WordForward => {
3395            execute_motion_with_block_vcol(ed, Motion::WordFwd, count);
3396        }
3397        crate::MotionKind::BigWordForward => {
3398            execute_motion_with_block_vcol(ed, Motion::BigWordFwd, count);
3399        }
3400        crate::MotionKind::WordBackward => {
3401            execute_motion_with_block_vcol(ed, Motion::WordBack, count);
3402        }
3403        crate::MotionKind::BigWordBackward => {
3404            execute_motion_with_block_vcol(ed, Motion::BigWordBack, count);
3405        }
3406        crate::MotionKind::WordEnd => {
3407            execute_motion_with_block_vcol(ed, Motion::WordEnd, count);
3408        }
3409        crate::MotionKind::BigWordEnd => {
3410            execute_motion_with_block_vcol(ed, Motion::BigWordEnd, count);
3411        }
3412        crate::MotionKind::LineStart => {
3413            // `0` / `<Home>`: first column of the current line.
3414            // count is ignored — matches vim `0` semantics.
3415            execute_motion_with_block_vcol(ed, Motion::LineStart, 1);
3416        }
3417        crate::MotionKind::FirstNonBlank => {
3418            // `^`: first non-blank column on the current line.
3419            // count is ignored — matches vim `^` semantics.
3420            execute_motion_with_block_vcol(ed, Motion::FirstNonBlank, 1);
3421        }
3422        crate::MotionKind::GotoLine => {
3423            // `G`: bare `G` → last line; `count G` → jump to line `count`.
3424            // apply_motion_kind normalises the raw count to count.max(1)
3425            // above, so count == 1 means "bare G" (last line) and count > 1
3426            // means "go to line N". execute_motion's FileBottom arm applies
3427            // the same `count > 1` check before calling move_bottom, so the
3428            // convention aligns: pass count straight through.
3429            // FileBottom is vertical — update_block_vcol is a no-op here
3430            // (preserves vcol), so the helper is safe to use.
3431            execute_motion_with_block_vcol(ed, Motion::FileBottom, count);
3432        }
3433        crate::MotionKind::LineEnd => {
3434            // `$` / `<End>`: last character on the current line.
3435            // count is ignored at the keymap-path level (vim `N$` moves
3436            // down N-1 lines then lands at line-end; not yet wired).
3437            execute_motion_with_block_vcol(ed, Motion::LineEnd, 1);
3438        }
3439        crate::MotionKind::FindRepeat => {
3440            // `;` — repeat last f/F/t/T in the same direction.
3441            // execute_motion resolves FindRepeat via ed.vim.last_find;
3442            // no-op if no prior find exists (None arm returns early).
3443            execute_motion_with_block_vcol(ed, Motion::FindRepeat { reverse: false }, count);
3444        }
3445        crate::MotionKind::FindRepeatReverse => {
3446            // `,` — repeat last f/F/t/T in the reverse direction.
3447            // execute_motion resolves FindRepeat via ed.vim.last_find;
3448            // no-op if no prior find exists (None arm returns early).
3449            execute_motion_with_block_vcol(ed, Motion::FindRepeat { reverse: true }, count);
3450        }
3451        crate::MotionKind::BracketMatch => {
3452            // `%` — jump to the matching bracket.
3453            // count is passed through; engine-side matching_bracket handles
3454            // the no-match case as a no-op (cursor stays). Engine FSM arm
3455            // for `%` in parse_motion is kept intact for macro-replay.
3456            execute_motion_with_block_vcol(ed, Motion::MatchBracket, count);
3457        }
3458        crate::MotionKind::ViewportTop => {
3459            // `H` — cursor to top of visible viewport, then count-1 rows down.
3460            // Engine FSM arm for `H` in parse_motion is kept intact for macro-replay.
3461            execute_motion_with_block_vcol(ed, Motion::ViewportTop, count);
3462        }
3463        crate::MotionKind::ViewportMiddle => {
3464            // `M` — cursor to middle of visible viewport; count ignored.
3465            // Engine FSM arm for `M` in parse_motion is kept intact for macro-replay.
3466            execute_motion_with_block_vcol(ed, Motion::ViewportMiddle, count);
3467        }
3468        crate::MotionKind::ViewportBottom => {
3469            // `L` — cursor to bottom of visible viewport, then count-1 rows up.
3470            // Engine FSM arm for `L` in parse_motion is kept intact for macro-replay.
3471            execute_motion_with_block_vcol(ed, Motion::ViewportBottom, count);
3472        }
3473        crate::MotionKind::HalfPageDown => {
3474            // `<C-d>` — half page down, count multiplies the distance.
3475            // Calls scroll_cursor_rows directly rather than adding a Motion enum
3476            // variant, keeping engine Motion churn minimal.
3477            scroll_cursor_rows(ed, viewport_half_rows(ed, count) as isize);
3478        }
3479        crate::MotionKind::HalfPageUp => {
3480            // `<C-u>` — half page up, count multiplies the distance.
3481            // Direct call mirrors the FSM Ctrl-u arm. No new Motion variant.
3482            scroll_cursor_rows(ed, -(viewport_half_rows(ed, count) as isize));
3483        }
3484        crate::MotionKind::FullPageDown => {
3485            // `<C-f>` — full page down (2-line overlap), count multiplies.
3486            // Direct call mirrors the FSM Ctrl-f arm. No new Motion variant.
3487            scroll_cursor_rows(ed, viewport_full_rows(ed, count) as isize);
3488        }
3489        crate::MotionKind::FullPageUp => {
3490            // `<C-b>` — full page up (2-line overlap), count multiplies.
3491            // Direct call mirrors the FSM Ctrl-b arm. No new Motion variant.
3492            scroll_cursor_rows(ed, -(viewport_full_rows(ed, count) as isize));
3493        }
3494        crate::MotionKind::FirstNonBlankLine => {
3495            execute_motion_with_block_vcol(ed, Motion::FirstNonBlankLine, count);
3496        }
3497        crate::MotionKind::SectionBackward => {
3498            execute_motion_with_block_vcol(ed, Motion::SectionBackward, count);
3499        }
3500        crate::MotionKind::SectionForward => {
3501            execute_motion_with_block_vcol(ed, Motion::SectionForward, count);
3502        }
3503        crate::MotionKind::SectionEndBackward => {
3504            execute_motion_with_block_vcol(ed, Motion::SectionEndBackward, count);
3505        }
3506        crate::MotionKind::SectionEndForward => {
3507            execute_motion_with_block_vcol(ed, Motion::SectionEndForward, count);
3508        }
3509    }
3510}
3511
3512/// Restore the cursor to the sticky column after vertical motions and
3513/// sync the sticky column to the current column after horizontal ones.
3514/// `pre_col` is the cursor column captured *before* the motion — used
3515/// to bootstrap the sticky value on the very first motion.
3516fn apply_sticky_col<H: crate::types::Host>(
3517    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3518    motion: &Motion,
3519    pre_col: usize,
3520) {
3521    if is_vertical_motion(motion) {
3522        let want = ed.sticky_col.unwrap_or(pre_col);
3523        // Record the desired column so the next vertical motion sees
3524        // it even if we currently clamped to a shorter row.
3525        ed.sticky_col = Some(want);
3526        let (row, _) = ed.cursor();
3527        let line_len = buf_line_chars(&ed.buffer, row);
3528        // Clamp to the last char on non-empty lines (vim normal-mode
3529        // never parks the cursor one past end of line). Empty lines
3530        // collapse to col 0.
3531        let max_col = line_len.saturating_sub(1);
3532        let target = want.min(max_col);
3533        // raw primitive: this function MUST preserve the un-clamped `want`
3534        // already stored in `ed.sticky_col`; `jump_cursor` would overwrite
3535        // it with the clamped `target`.
3536        buf_set_cursor_rc(&mut ed.buffer, row, target);
3537    } else {
3538        // Horizontal motion or non-motion: sticky column tracks the
3539        // new cursor column so the *next* vertical motion aims there.
3540        ed.sticky_col = Some(ed.cursor().1);
3541    }
3542}
3543
3544fn is_vertical_motion(motion: &Motion) -> bool {
3545    // Only j / k preserve the sticky column. Everything else (search,
3546    // gg / G, word jumps, etc.) lands at the match's own column so the
3547    // sticky value should sync to the new cursor column.
3548    matches!(
3549        motion,
3550        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown
3551    )
3552}
3553
3554fn apply_motion_cursor<H: crate::types::Host>(
3555    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3556    motion: &Motion,
3557    count: usize,
3558) {
3559    apply_motion_cursor_ctx(ed, motion, count, false)
3560}
3561
3562pub(crate) fn apply_motion_cursor_ctx<H: crate::types::Host>(
3563    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3564    motion: &Motion,
3565    count: usize,
3566    as_operator: bool,
3567) {
3568    // Clamp the count where it fans out into the per-motion `0..count` walk.
3569    // Two bounds:
3570    //  - vim's documented ceiling (`:h count`) for folded counts; and
3571    //  - the buffer's character count, since a motion can never make progress
3572    //    past the end of the buffer — without this a pathological prefix
3573    //    (`999999999w`, `<big>dw`) would spin the walk up to ~1e9 times,
3574    //    freezing the UI, even though the result is identical to stopping at
3575    //    the buffer edge.
3576    let count = count
3577        .min(MAX_COUNT)
3578        .min(ed.buffer.rope().len_chars().saturating_add(1));
3579    match motion {
3580        Motion::Left => {
3581            // `h` — Buffer clamps at col 0 (no wrap), matching vim.
3582            crate::motions::move_left(&mut ed.buffer, count);
3583            ed.push_buffer_cursor_to_textarea();
3584        }
3585        Motion::Right => {
3586            // `l` — operator-motion context (`dl`/`cl`/`yl`) is allowed
3587            // one past the last char so the range includes it; cursor
3588            // context clamps at the last char.
3589            if as_operator {
3590                crate::motions::move_right_to_end(&mut ed.buffer, count);
3591            } else {
3592                crate::motions::move_right_in_line(&mut ed.buffer, count);
3593            }
3594            ed.push_buffer_cursor_to_textarea();
3595        }
3596        Motion::SpaceFwd => {
3597            // `<Space>` — wraps to next line at EOL in cursor context; mid-line
3598            // char delete like `l` under an operator (`d<Space>`).
3599            if as_operator {
3600                crate::motions::move_right_to_end(&mut ed.buffer, count);
3601            } else {
3602                crate::motions::move_space_fwd(&mut ed.buffer, count);
3603            }
3604            ed.push_buffer_cursor_to_textarea();
3605        }
3606        Motion::BackspaceBack => {
3607            // `<BS>` — wraps to prev line's last char at BOL in cursor context;
3608            // mid-line char move like `h` under an operator (`d<BS>`).
3609            if as_operator {
3610                crate::motions::move_left(&mut ed.buffer, count);
3611            } else {
3612                crate::motions::move_backspace_back(&mut ed.buffer, count);
3613            }
3614            ed.push_buffer_cursor_to_textarea();
3615        }
3616        Motion::Up => {
3617            // Final col is set by `apply_sticky_col` below — push the
3618            // post-move row to the textarea and let sticky tracking
3619            // finish the work.
3620            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3621            crate::motions::move_up(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
3622            ed.push_buffer_cursor_to_textarea();
3623        }
3624        Motion::Down => {
3625            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3626            crate::motions::move_down(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
3627            ed.push_buffer_cursor_to_textarea();
3628        }
3629        Motion::ScreenUp => {
3630            let v = *ed.host.viewport();
3631            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3632            crate::motions::move_screen_up(&mut ed.buffer, &folds, &v, count, &mut ed.sticky_col);
3633            ed.push_buffer_cursor_to_textarea();
3634        }
3635        Motion::ScreenDown => {
3636            let v = *ed.host.viewport();
3637            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3638            crate::motions::move_screen_down(&mut ed.buffer, &folds, &v, count, &mut ed.sticky_col);
3639            ed.push_buffer_cursor_to_textarea();
3640        }
3641        Motion::WordFwd => {
3642            crate::motions::move_word_fwd(&mut ed.buffer, false, count, &ed.settings.iskeyword);
3643            ed.push_buffer_cursor_to_textarea();
3644        }
3645        Motion::WordBack => {
3646            crate::motions::move_word_back(&mut ed.buffer, false, count, &ed.settings.iskeyword);
3647            ed.push_buffer_cursor_to_textarea();
3648        }
3649        Motion::WordEnd => {
3650            crate::motions::move_word_end(&mut ed.buffer, false, count, &ed.settings.iskeyword);
3651            ed.push_buffer_cursor_to_textarea();
3652        }
3653        Motion::BigWordFwd => {
3654            crate::motions::move_word_fwd(&mut ed.buffer, true, count, &ed.settings.iskeyword);
3655            ed.push_buffer_cursor_to_textarea();
3656        }
3657        Motion::BigWordBack => {
3658            crate::motions::move_word_back(&mut ed.buffer, true, count, &ed.settings.iskeyword);
3659            ed.push_buffer_cursor_to_textarea();
3660        }
3661        Motion::BigWordEnd => {
3662            crate::motions::move_word_end(&mut ed.buffer, true, count, &ed.settings.iskeyword);
3663            ed.push_buffer_cursor_to_textarea();
3664        }
3665        Motion::WordEndBack => {
3666            crate::motions::move_word_end_back(
3667                &mut ed.buffer,
3668                false,
3669                count,
3670                &ed.settings.iskeyword,
3671            );
3672            ed.push_buffer_cursor_to_textarea();
3673        }
3674        Motion::BigWordEndBack => {
3675            crate::motions::move_word_end_back(&mut ed.buffer, true, count, &ed.settings.iskeyword);
3676            ed.push_buffer_cursor_to_textarea();
3677        }
3678        Motion::LineStart => {
3679            crate::motions::move_line_start(&mut ed.buffer);
3680            ed.push_buffer_cursor_to_textarea();
3681        }
3682        Motion::FirstNonBlank => {
3683            crate::motions::move_first_non_blank(&mut ed.buffer);
3684            ed.push_buffer_cursor_to_textarea();
3685        }
3686        Motion::LineEnd => {
3687            // Vim normal-mode `$` lands on the last char, not one past it.
3688            crate::motions::move_line_end(&mut ed.buffer);
3689            ed.push_buffer_cursor_to_textarea();
3690        }
3691        Motion::FileTop => {
3692            // `count gg` jumps to line `count` (first non-blank);
3693            // bare `gg` lands at the top.
3694            if count > 1 {
3695                crate::motions::move_bottom(&mut ed.buffer, count);
3696            } else {
3697                crate::motions::move_top(&mut ed.buffer);
3698            }
3699            ed.push_buffer_cursor_to_textarea();
3700        }
3701        Motion::FileBottom => {
3702            // `count G` jumps to line `count`; bare `G` lands at
3703            // the buffer bottom (`Buffer::move_bottom(0)`).
3704            if count > 1 {
3705                crate::motions::move_bottom(&mut ed.buffer, count);
3706            } else {
3707                crate::motions::move_bottom(&mut ed.buffer, 0);
3708            }
3709            ed.push_buffer_cursor_to_textarea();
3710        }
3711        Motion::Find { ch, forward, till } => {
3712            // Skip an adjacent target when this is a `;`/`,` repeat, and on the
3713            // 2nd..Nth step of a counted `t`/`T` (the cursor lands one cell
3714            // short each time, so a naive repeat would stick).
3715            let repeat = std::mem::take(&mut ed.vim.find_repeat_skip);
3716            for i in 0..count {
3717                let skip_adjacent = repeat || i > 0;
3718                if !find_char_on_line(ed, *ch, *forward, *till, skip_adjacent) {
3719                    break;
3720                }
3721            }
3722        }
3723        Motion::FindRepeat { .. } => {} // already resolved upstream
3724        Motion::MatchBracket => {
3725            let _ = matching_bracket(ed);
3726        }
3727        Motion::UnmatchedBracket { forward, open } => {
3728            goto_unmatched_bracket(ed, *forward, *open, count);
3729        }
3730        Motion::WordAtCursor {
3731            forward,
3732            whole_word,
3733        } => {
3734            word_at_cursor_search(ed, *forward, *whole_word, count);
3735        }
3736        Motion::SearchNext { reverse } => {
3737            // Re-push the last query so the buffer's search state is
3738            // correct even if the host happened to clear it (e.g. while
3739            // a Visual mode draw was in progress).
3740            if let Some(pattern) = ed.vim.last_search.clone() {
3741                ed.push_search_pattern(&pattern);
3742            }
3743            if ed.search_state().pattern.is_none() {
3744                return;
3745            }
3746            // `n` repeats the last search in its committed direction;
3747            // `N` inverts. So a `?` search makes `n` walk backward and
3748            // `N` walk forward.
3749            let forward = ed.vim.last_search_forward != *reverse;
3750            for _ in 0..count.max(1) {
3751                if forward {
3752                    ed.search_advance_forward(true);
3753                } else {
3754                    ed.search_advance_backward(true);
3755                }
3756            }
3757            ed.push_buffer_cursor_to_textarea();
3758        }
3759        Motion::ViewportTop => {
3760            let v = *ed.host().viewport();
3761            crate::motions::move_viewport_top(&mut ed.buffer, &v, count.saturating_sub(1));
3762            ed.push_buffer_cursor_to_textarea();
3763        }
3764        Motion::ViewportMiddle => {
3765            let v = *ed.host().viewport();
3766            crate::motions::move_viewport_middle(&mut ed.buffer, &v);
3767            ed.push_buffer_cursor_to_textarea();
3768        }
3769        Motion::ViewportBottom => {
3770            let v = *ed.host().viewport();
3771            crate::motions::move_viewport_bottom(&mut ed.buffer, &v, count.saturating_sub(1));
3772            ed.push_buffer_cursor_to_textarea();
3773        }
3774        Motion::LastNonBlank => {
3775            crate::motions::move_last_non_blank(&mut ed.buffer);
3776            ed.push_buffer_cursor_to_textarea();
3777        }
3778        Motion::LineMiddle => {
3779            let row = ed.cursor().0;
3780            let line_chars = buf_line_chars(&ed.buffer, row);
3781            // Vim's `gM`: column = floor(chars / 2). Empty / single-char
3782            // lines stay at col 0.
3783            let target = line_chars / 2;
3784            ed.jump_cursor(row, target);
3785        }
3786        Motion::ScreenLineMiddle => {
3787            // Vim's `gm`: middle of the *screen* line = column
3788            // `viewport_width / 2`, clamped to the last char of the line.
3789            let row = ed.cursor().0;
3790            let width = ed.host().viewport().width as usize;
3791            let last = buf_line_chars(&ed.buffer, row).saturating_sub(1);
3792            let target = (width / 2).min(last);
3793            ed.jump_cursor(row, target);
3794        }
3795        Motion::ParagraphPrev => {
3796            crate::motions::move_paragraph_prev(&mut ed.buffer, count);
3797            ed.push_buffer_cursor_to_textarea();
3798        }
3799        Motion::ParagraphNext => {
3800            crate::motions::move_paragraph_next(&mut ed.buffer, count);
3801            ed.push_buffer_cursor_to_textarea();
3802        }
3803        Motion::SentencePrev => {
3804            for _ in 0..count.max(1) {
3805                if let Some((row, col)) = sentence_boundary(ed, false) {
3806                    ed.jump_cursor(row, col);
3807                }
3808            }
3809        }
3810        Motion::SentenceNext => {
3811            for _ in 0..count.max(1) {
3812                if let Some((row, col)) = sentence_boundary(ed, true) {
3813                    ed.jump_cursor(row, col);
3814                }
3815            }
3816        }
3817        Motion::SectionBackward => {
3818            crate::motions::move_section_backward(&mut ed.buffer, count);
3819            ed.push_buffer_cursor_to_textarea();
3820        }
3821        Motion::SectionForward => {
3822            crate::motions::move_section_forward(&mut ed.buffer, count);
3823            ed.push_buffer_cursor_to_textarea();
3824        }
3825        Motion::SectionEndBackward => {
3826            crate::motions::move_section_end_backward(&mut ed.buffer, count);
3827            ed.push_buffer_cursor_to_textarea();
3828        }
3829        Motion::SectionEndForward => {
3830            crate::motions::move_section_end_forward(&mut ed.buffer, count);
3831            ed.push_buffer_cursor_to_textarea();
3832        }
3833        Motion::FirstNonBlankNextLine => {
3834            crate::motions::move_first_non_blank_next_line(&mut ed.buffer, count);
3835            ed.push_buffer_cursor_to_textarea();
3836        }
3837        Motion::FirstNonBlankPrevLine => {
3838            crate::motions::move_first_non_blank_prev_line(&mut ed.buffer, count);
3839            ed.push_buffer_cursor_to_textarea();
3840        }
3841        Motion::FirstNonBlankLine => {
3842            crate::motions::move_first_non_blank_line(&mut ed.buffer, count);
3843            ed.push_buffer_cursor_to_textarea();
3844        }
3845        Motion::GotoColumn => {
3846            crate::motions::move_goto_column(&mut ed.buffer, count);
3847            ed.push_buffer_cursor_to_textarea();
3848        }
3849    }
3850}
3851
3852fn move_first_non_whitespace<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
3853    // Some call sites invoke this right after `dd` / `<<` / `>>` etc
3854    // mutates the textarea content, so the migration buffer hasn't
3855    // seen the new lines OR new cursor yet. Mirror the full content
3856    // across before delegating, then push the result back so the
3857    // textarea reflects the resolved column too.
3858    ed.sync_buffer_content_from_textarea();
3859    crate::motions::move_first_non_blank(&mut ed.buffer);
3860    ed.push_buffer_cursor_to_textarea();
3861}
3862
3863fn find_char_on_line<H: crate::types::Host>(
3864    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3865    ch: char,
3866    forward: bool,
3867    till: bool,
3868    skip_adjacent: bool,
3869) -> bool {
3870    let moved = crate::motions::find_char_on_line(&mut ed.buffer, ch, forward, till, skip_adjacent);
3871    if moved {
3872        ed.push_buffer_cursor_to_textarea();
3873    }
3874    moved
3875}
3876
3877fn matching_bracket<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) -> bool {
3878    let moved = crate::motions::match_bracket(&mut ed.buffer);
3879    if moved {
3880        ed.push_buffer_cursor_to_textarea();
3881    }
3882    moved
3883}
3884
3885/// `[(` / `])` / `[{` / `]}` — move to the `count`-th previous (`forward =
3886/// false`) / next (`forward = true`) unmatched bracket of the kind given by
3887/// `open` (`(` or `{`). Balanced inner pairs are skipped via a depth counter.
3888fn goto_unmatched_bracket<H: crate::types::Host>(
3889    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3890    forward: bool,
3891    open: char,
3892    count: usize,
3893) {
3894    let close = match open {
3895        '(' => ')',
3896        '{' => '}',
3897        _ => return,
3898    };
3899    let cursor = buf_cursor_pos(&ed.buffer);
3900    let rows = buf_row_count(&ed.buffer);
3901    let target = count.max(1);
3902    let mut found = 0usize;
3903    let mut depth = 0i32;
3904
3905    if forward {
3906        let mut r = cursor.row;
3907        let mut from_col = cursor.col + 1;
3908        while r < rows {
3909            let line: Vec<char> = buf_line(&ed.buffer, r)
3910                .unwrap_or_default()
3911                .chars()
3912                .collect();
3913            let mut ci = from_col;
3914            while ci < line.len() {
3915                let ch = line[ci];
3916                if ch == open {
3917                    depth += 1;
3918                } else if ch == close {
3919                    if depth == 0 {
3920                        found += 1;
3921                        if found == target {
3922                            buf_set_cursor_rc(&mut ed.buffer, r, ci);
3923                            ed.push_buffer_cursor_to_textarea();
3924                            return;
3925                        }
3926                    } else {
3927                        depth -= 1;
3928                    }
3929                }
3930                ci += 1;
3931            }
3932            r += 1;
3933            from_col = 0;
3934        }
3935    } else {
3936        let mut r = cursor.row as isize;
3937        // First row scans from the column left of the cursor; earlier rows from
3938        // their last column (`isize::MAX` clamps to `len - 1`).
3939        let mut from_col = cursor.col as isize - 1;
3940        while r >= 0 {
3941            let line: Vec<char> = buf_line(&ed.buffer, r as usize)
3942                .unwrap_or_default()
3943                .chars()
3944                .collect();
3945            let mut ci = from_col.min(line.len() as isize - 1);
3946            while ci >= 0 {
3947                let ch = line[ci as usize];
3948                if ch == close {
3949                    depth += 1;
3950                } else if ch == open {
3951                    if depth == 0 {
3952                        found += 1;
3953                        if found == target {
3954                            buf_set_cursor_rc(&mut ed.buffer, r as usize, ci as usize);
3955                            ed.push_buffer_cursor_to_textarea();
3956                            return;
3957                        }
3958                    } else {
3959                        depth -= 1;
3960                    }
3961                }
3962                ci -= 1;
3963            }
3964            r -= 1;
3965            from_col = isize::MAX;
3966        }
3967    }
3968}
3969
3970fn word_at_cursor_search<H: crate::types::Host>(
3971    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3972    forward: bool,
3973    whole_word: bool,
3974    count: usize,
3975) {
3976    let (row, col) = ed.cursor();
3977    let line: String = buf_line(&ed.buffer, row).unwrap_or_default();
3978    let chars: Vec<char> = line.chars().collect();
3979    if chars.is_empty() {
3980        return;
3981    }
3982    // Expand around cursor to a word boundary.
3983    let spec = ed.settings().iskeyword.clone();
3984    let is_word = |c: char| is_keyword_char(c, &spec);
3985    let mut start = col.min(chars.len().saturating_sub(1));
3986    while start > 0 && is_word(chars[start - 1]) {
3987        start -= 1;
3988    }
3989    let mut end = start;
3990    while end < chars.len() && is_word(chars[end]) {
3991        end += 1;
3992    }
3993    if end <= start {
3994        return;
3995    }
3996    let word: String = chars[start..end].iter().collect();
3997    let escaped = regex_escape(&word);
3998    let pattern = if whole_word {
3999        format!(r"\b{escaped}\b")
4000    } else {
4001        escaped
4002    };
4003    ed.push_search_pattern(&pattern);
4004    if ed.search_state().pattern.is_none() {
4005        return;
4006    }
4007    // Remember the query so `n` / `N` keep working after the jump.
4008    ed.vim.last_search = Some(pattern);
4009    ed.vim.last_search_forward = forward;
4010    for _ in 0..count.max(1) {
4011        if forward {
4012            ed.search_advance_forward(true);
4013        } else {
4014            ed.search_advance_backward(true);
4015        }
4016    }
4017    ed.push_buffer_cursor_to_textarea();
4018}
4019
4020fn regex_escape(s: &str) -> String {
4021    let mut out = String::with_capacity(s.len());
4022    for c in s.chars() {
4023        if matches!(
4024            c,
4025            '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\'
4026        ) {
4027            out.push('\\');
4028        }
4029        out.push(c);
4030    }
4031    out
4032}
4033
4034// ─── Operator application ──────────────────────────────────────────────────
4035
4036/// Public(crate) entry: apply operator over the motion identified by a raw
4037/// char key. Called by `Editor::apply_op_motion` (the public controller API)
4038/// so the hjkl-vim pending-state reducer can dispatch `ApplyOpMotion` without
4039/// re-entering the FSM.
4040///
4041/// Applies standard vim quirks:
4042/// - `cw` / `cW` → `ce` / `cE`
4043/// - `FindRepeat` → resolves against `last_find`
4044/// - Updates `last_find` and `last_change` per existing conventions.
4045///
4046/// No-op when `motion_key` does not produce a known motion.
4047pub(crate) fn apply_op_motion_key<H: crate::types::Host>(
4048    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4049    op: Operator,
4050    motion_key: char,
4051    total_count: usize,
4052) {
4053    let input = Input {
4054        key: Key::Char(motion_key),
4055        ctrl: false,
4056        alt: false,
4057        shift: false,
4058    };
4059    let Some(motion) = parse_motion(&input) else {
4060        return;
4061    };
4062    // Vim quirk (`:h cw`): `cw`/`cW` act like `ce`/`cE` — but ONLY when the
4063    // cursor is on a non-blank. On whitespace, `cw` behaves like `dw` (changes
4064    // just the whitespace up to the next word), so the conversion is skipped.
4065    let cursor_on_nonblank = {
4066        let (r, c) = ed.cursor();
4067        buf_line(&ed.buffer, r)
4068            .and_then(|l| l.chars().nth(c))
4069            .map(|ch| !ch.is_whitespace())
4070            .unwrap_or(false)
4071    };
4072    let motion = match motion {
4073        Motion::FindRepeat { reverse } => match ed.vim.last_find {
4074            Some((ch, forward, till)) => Motion::Find {
4075                ch,
4076                forward: if reverse { !forward } else { forward },
4077                till,
4078            },
4079            None => return,
4080        },
4081        Motion::WordFwd if op == Operator::Change && cursor_on_nonblank => Motion::WordEnd,
4082        Motion::BigWordFwd if op == Operator::Change && cursor_on_nonblank => Motion::BigWordEnd,
4083        m => m,
4084    };
4085    apply_op_with_motion(ed, op, &motion, total_count);
4086    if let Motion::Find { ch, forward, till } = &motion {
4087        ed.vim.last_find = Some((*ch, *forward, *till));
4088    }
4089    if !ed.vim.replaying && op_is_change(op) {
4090        ed.vim.last_change = Some(LastChange::OpMotion {
4091            op,
4092            motion,
4093            count: total_count,
4094            inserted: None,
4095        });
4096    }
4097}
4098
4099/// Public(crate) entry: apply doubled-letter line op (`dd`/`yy`/`cc`/`>>`/`<<`/`gcc`).
4100/// Called by `Editor::apply_op_double` (the public controller API).
4101pub(crate) fn apply_op_double<H: crate::types::Host>(
4102    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4103    op: Operator,
4104    total_count: usize,
4105) {
4106    if op == Operator::Comment {
4107        // `gcc` / `{N}gcc` — toggle comment on `total_count` lines starting at cursor.
4108        let row = buf_cursor_pos(&ed.buffer).row;
4109        let end_row = (row + total_count.max(1) - 1).min(ed.buffer.row_count().saturating_sub(1));
4110        ed.toggle_comment_range(row, end_row);
4111        ed.vim.mode = Mode::Normal;
4112        if !ed.vim.replaying {
4113            ed.vim.last_change = Some(LastChange::LineOp {
4114                op,
4115                count: total_count,
4116                inserted: None,
4117            });
4118        }
4119        return;
4120    }
4121    execute_line_op(ed, op, total_count);
4122    if !ed.vim.replaying {
4123        ed.vim.last_change = Some(LastChange::LineOp {
4124            op,
4125            count: total_count,
4126            inserted: None,
4127        });
4128    }
4129}
4130
4131/// Compute the `gn` / `gN` target match as a `(start, end_inclusive)` pair.
4132/// When the cursor sits inside a match, that match is the target; otherwise the
4133/// next match (forward) or previous match (backward) is used. Returns `None`
4134/// when there is no pattern or no match remains.
4135fn gn_find_range<H: crate::types::Host>(
4136    ed: &Editor<hjkl_buffer::Buffer, H>,
4137    re: &regex::Regex,
4138    forward: bool,
4139) -> Option<(crate::types::Pos, crate::types::Pos)> {
4140    use crate::types::{Cursor, Pos, Search};
4141    let cursor = Cursor::cursor(&ed.buffer);
4142    let contains =
4143        Search::find_prev(&ed.buffer, cursor, re).filter(|m| m.start <= cursor && cursor < m.end);
4144    let range = if let Some(m) = contains {
4145        m
4146    } else if forward {
4147        Search::find_next(&ed.buffer, cursor, re)?
4148    } else {
4149        Search::find_prev(&ed.buffer, cursor, re)?
4150    };
4151    let end_incl = if range.end.col > 0 {
4152        Pos::new(range.end.line, range.end.col - 1)
4153    } else {
4154        range.end
4155    };
4156    Some((range.start, end_incl))
4157}
4158
4159/// `gn` / `gN` — operate on (or select) the search match. `op = None` enters
4160/// Visual mode with the match selected; `Some(op)` applies the operator to the
4161/// match as a charwise inclusive range. Records `LastChange::GnOp` so `cgn` /
4162/// `dgn` are `.`-repeatable.
4163pub(crate) fn gn_operate<H: crate::types::Host>(
4164    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4165    op: Option<Operator>,
4166    forward: bool,
4167    count: usize,
4168) {
4169    use crate::types::{Cursor, Pos};
4170    // Make sure the compiled pattern reflects the last `/` or `*` search.
4171    if let Some(p) = ed.vim.last_search.clone() {
4172        ed.push_search_pattern(&p);
4173    }
4174    let Some(re) = ed.search_state().pattern.clone() else {
4175        return;
4176    };
4177    ed.sync_buffer_content_from_textarea();
4178
4179    let Some(mut range) = gn_find_range(ed, &re, forward) else {
4180        return;
4181    };
4182    // `[count]gn` walks to the count-th match.
4183    for _ in 1..count.max(1) {
4184        let past = Pos::new(range.1.line, range.1.col + 1);
4185        Cursor::set_cursor(&mut ed.buffer, past);
4186        match gn_find_range(ed, &re, forward) {
4187            Some(r) => range = r,
4188            None => break,
4189        }
4190    }
4191    let start_t = (range.0.line as usize, range.0.col as usize);
4192    let end_t = (range.1.line as usize, range.1.col as usize);
4193
4194    match op {
4195        None => {
4196            // Bare `gn` — select the match in Visual mode.
4197            ed.vim.visual_anchor = start_t;
4198            buf_set_cursor_rc(&mut ed.buffer, end_t.0, end_t.1);
4199            ed.vim.mode = Mode::Visual;
4200            ed.vim.current_mode = crate::VimMode::Visual;
4201            ed.push_buffer_cursor_to_textarea();
4202        }
4203        Some(Operator::Delete) => {
4204            ed.push_undo();
4205            cut_vim_range(ed, start_t, end_t, RangeKind::Inclusive);
4206            // Deleting at the line end can leave the cursor one past the last
4207            // char; vim clamps it back onto the line.
4208            clamp_cursor_to_normal_mode(ed);
4209            ed.push_buffer_cursor_to_textarea();
4210            if !ed.vim.replaying {
4211                ed.vim.last_change = Some(LastChange::GnOp {
4212                    op: Operator::Delete,
4213                    forward,
4214                    inserted: None,
4215                });
4216            }
4217        }
4218        Some(Operator::Change) => {
4219            ed.push_undo();
4220            ed.vim.change_mark_start = Some(start_t);
4221            cut_vim_range(ed, start_t, end_t, RangeKind::Inclusive);
4222            if !ed.vim.replaying {
4223                ed.vim.last_change = Some(LastChange::GnOp {
4224                    op: Operator::Change,
4225                    forward,
4226                    inserted: None,
4227                });
4228            }
4229            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
4230        }
4231        Some(Operator::Yank) => {
4232            let text = read_vim_range(ed, start_t, end_t, RangeKind::Inclusive);
4233            if !text.is_empty() {
4234                ed.record_yank_to_host(text.clone());
4235                ed.record_yank(text, false);
4236            }
4237            buf_set_cursor_rc(&mut ed.buffer, start_t.0, start_t.1);
4238            ed.push_buffer_cursor_to_textarea();
4239        }
4240        Some(other @ (Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase)) => {
4241            // Case op over a gn match: apply as a charwise op over the
4242            // inclusive range.
4243            ed.push_undo();
4244            apply_case_op_to_selection(ed, other, start_t, end_t, RangeKind::Inclusive);
4245        }
4246        Some(_) => {}
4247    }
4248}
4249
4250/// Shared implementation: apply operator over a g-chord motion or case-op
4251/// linewise form. Called by `Editor::apply_op_g` (the public controller API)
4252/// so the hjkl-vim reducer can dispatch `ApplyOpG` without re-entering the FSM.
4253///
4254/// - If `op` is Uppercase/Lowercase/ToggleCase and `ch` matches the op's char
4255///   (`U`/`u`/`~`): executes the line op and updates `last_change`.
4256/// - `n` / `N` operate on the search match (`dgn` / `cgn`).
4257/// - Otherwise, maps `ch` to a motion (`g`→FileTop, `e`→WordEndBack,
4258///   `E`→BigWordEndBack, `j`→ScreenDown, `k`→ScreenUp) and applies. Unknown
4259///   chars are silently ignored (no-op), matching the engine FSM's behaviour.
4260pub(crate) fn apply_op_g_inner<H: crate::types::Host>(
4261    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4262    op: Operator,
4263    ch: char,
4264    total_count: usize,
4265) {
4266    // Case-op linewise form: `gUgU`, `gugu`, `g~g~`, `g?g?` — same effect as
4267    // `gUU` / `guu` / `g~~` / `g??`.
4268    if matches!(
4269        op,
4270        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13
4271    ) {
4272        let op_char = match op {
4273            Operator::Uppercase => 'U',
4274            Operator::Lowercase => 'u',
4275            Operator::ToggleCase => '~',
4276            Operator::Rot13 => '?',
4277            _ => unreachable!(),
4278        };
4279        if ch == op_char {
4280            execute_line_op(ed, op, total_count);
4281            if !ed.vim.replaying {
4282                ed.vim.last_change = Some(LastChange::LineOp {
4283                    op,
4284                    count: total_count,
4285                    inserted: None,
4286                });
4287            }
4288            return;
4289        }
4290    }
4291    // `dgn` / `cgn` / `ygn` (and `gN` forms) — operate on the search match.
4292    if ch == 'n' || ch == 'N' {
4293        gn_operate(ed, Some(op), ch == 'n', total_count);
4294        return;
4295    }
4296    let motion = match ch {
4297        'g' => Motion::FileTop,
4298        'e' => Motion::WordEndBack,
4299        'E' => Motion::BigWordEndBack,
4300        'j' => Motion::ScreenDown,
4301        'k' => Motion::ScreenUp,
4302        _ => return, // Unknown char — no-op.
4303    };
4304    apply_op_with_motion(ed, op, &motion, total_count);
4305    if !ed.vim.replaying && op_is_change(op) {
4306        ed.vim.last_change = Some(LastChange::OpMotion {
4307            op,
4308            motion,
4309            count: total_count,
4310            inserted: None,
4311        });
4312    }
4313}
4314
4315/// Public(crate) entry point for bare `g<x>`. Applies the g-chord effect
4316/// given the char `ch` and pre-captured `count`. Called by `Editor::after_g`
4317/// (the public controller API) so the hjkl-vim pending-state reducer can
4318/// dispatch `AfterGChord` without re-entering the FSM.
4319pub(crate) fn apply_after_g<H: crate::types::Host>(
4320    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4321    ch: char,
4322    count: usize,
4323) {
4324    match ch {
4325        'g' => {
4326            // gg — top / jump to line count.
4327            let pre = ed.cursor();
4328            if count > 1 {
4329                ed.jump_cursor(count - 1, 0);
4330            } else {
4331                ed.jump_cursor(0, 0);
4332            }
4333            move_first_non_whitespace(ed);
4334            // Update sticky_col to the first-non-blank column so j/k after
4335            // gg aim for the correct column per vim semantics.
4336            ed.sticky_col = Some(ed.cursor().1);
4337            if ed.cursor() != pre {
4338                ed.push_jump(pre);
4339            }
4340        }
4341        'e' => execute_motion(ed, Motion::WordEndBack, count),
4342        'E' => execute_motion(ed, Motion::BigWordEndBack, count),
4343        // `g_` — last non-blank on the line.
4344        '_' => execute_motion(ed, Motion::LastNonBlank, count),
4345        // `gM` — middle char column of the current line.
4346        'M' => execute_motion(ed, Motion::LineMiddle, count),
4347        // `gm` — middle of the screen line (viewport_width/2, clamped to EOL).
4348        'm' => execute_motion(ed, Motion::ScreenLineMiddle, count),
4349        // `gv` — re-enter the last visual selection.
4350        // Phase 6.6a: drive through the public Editor API.
4351        'v' => ed.reenter_last_visual(),
4352        // `gj` / `gk` — display-line down / up. Walks one screen
4353        // segment at a time under `:set wrap`; falls back to `j`/`k`
4354        // when wrap is off (Buffer::move_screen_* handles the branch).
4355        'j' => execute_motion(ed, Motion::ScreenDown, count),
4356        'k' => execute_motion(ed, Motion::ScreenUp, count),
4357        // Case operators: `gU` / `gu` / `g~`. Enter operator-pending
4358        // so the next input is treated as the motion / text object /
4359        // shorthand double (`gUU`, `guu`, `g~~`).
4360        'U' => {
4361            ed.vim.pending = Pending::Op {
4362                op: Operator::Uppercase,
4363                count1: count,
4364            };
4365        }
4366        'u' => {
4367            ed.vim.pending = Pending::Op {
4368                op: Operator::Lowercase,
4369                count1: count,
4370            };
4371        }
4372        '~' => {
4373            ed.vim.pending = Pending::Op {
4374                op: Operator::ToggleCase,
4375                count1: count,
4376            };
4377        }
4378        '?' => {
4379            // `g?{motion}` — ROT13 operator (`g??` / `g?g?` doubled).
4380            ed.vim.pending = Pending::Op {
4381                op: Operator::Rot13,
4382                count1: count,
4383            };
4384        }
4385        'q' => {
4386            // `gq{motion}` — text reflow operator. Subsequent motion
4387            // / textobj rides the same operator pipeline.
4388            ed.vim.pending = Pending::Op {
4389                op: Operator::Reflow,
4390                count1: count,
4391            };
4392        }
4393        'w' => {
4394            // `gw{motion}` — same reflow as `gq` but cursor stays at
4395            // its pre-reflow position (clamped to new EOL if shorter).
4396            ed.vim.pending = Pending::Op {
4397                op: Operator::ReflowKeepCursor,
4398                count1: count,
4399            };
4400        }
4401        'J' => {
4402            // `gJ` — join line below without inserting a space. `[count]gJ`
4403            // joins `count` lines (`count - 1` joins), like `J`.
4404            let joins = count.max(2) - 1;
4405            for _ in 0..joins {
4406                ed.push_undo();
4407                if !join_line_raw(ed) {
4408                    break;
4409                }
4410            }
4411            if !ed.vim.replaying {
4412                ed.vim.last_change = Some(LastChange::JoinLine { count: joins });
4413            }
4414        }
4415        'd' => {
4416            // `gd` — goto definition. hjkl-engine doesn't run an LSP
4417            // itself; raise an intent the host drains and routes to
4418            // `sqls`. The cursor stays put here — the host moves it
4419            // once it has the target location.
4420            ed.pending_lsp = Some(crate::editor::LspIntent::GotoDefinition);
4421        }
4422        // `gi` — go to last-insert position and re-enter insert mode.
4423        // Matches vim's `:h gi`: moves to the `'^` mark position (the
4424        // cursor where insert mode was last active, before Esc step-back)
4425        // and enters insert mode there.
4426        'i' => {
4427            if let Some((row, col)) = ed.vim.last_insert_pos {
4428                ed.jump_cursor(row, col);
4429            }
4430            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::I));
4431        }
4432        // `gc` — enter operator-pending for the comment-toggle operator.
4433        // `gcc` (doubled 'c') is the line-wise form; `gc{motion}` is the
4434        // motion form. The operator is Comment — the app layer (or the
4435        // doubled-char path in handle_after_op) calls toggle_comment_range.
4436        'c' => {
4437            ed.vim.pending = Pending::Op {
4438                op: Operator::Comment,
4439                count1: count,
4440            };
4441        }
4442        // `gp` / `gP` — paste like `p`/`P` but leave the cursor just after
4443        // the pasted text.
4444        'p' => paste_bridge(ed, false, count.max(1), true, false),
4445        'P' => paste_bridge(ed, true, count.max(1), true, false),
4446        // `gn` / `gN` — select the next / previous search match in Visual mode.
4447        'n' => gn_operate(ed, None, true, count.max(1)),
4448        'N' => gn_operate(ed, None, false, count.max(1)),
4449        // `g;` / `g,` — walk the change list. `g;` toward older
4450        // entries, `g,` toward newer.
4451        ';' => walk_change_list(ed, -1, count.max(1)),
4452        ',' => walk_change_list(ed, 1, count.max(1)),
4453        // `g*` / `g#` — like `*` / `#` but match substrings (no `\b`
4454        // boundary anchors), so the cursor on `foo` finds it inside
4455        // `foobar` too.
4456        '*' => execute_motion(
4457            ed,
4458            Motion::WordAtCursor {
4459                forward: true,
4460                whole_word: false,
4461            },
4462            count,
4463        ),
4464        '#' => execute_motion(
4465            ed,
4466            Motion::WordAtCursor {
4467                forward: false,
4468                whole_word: false,
4469            },
4470            count,
4471        ),
4472        // `g&` — repeat last `:s` over the whole buffer (1,$), keeping all
4473        // original flags. Equivalent to `:%s//~/&` in vim.
4474        '&' => {
4475            let cmd = match ed.vim.last_substitute.clone() {
4476                Some(c) => c,
4477                None => {
4478                    // No prior substitute — mirror the `:&` error path; do
4479                    // nothing to the buffer (the host's status line will show
4480                    // the pending error if wired; for headless / test hosts
4481                    // we simply return silently).
4482                    return;
4483                }
4484            };
4485            let last_row = buf_row_count(&ed.buffer).saturating_sub(1) as u32;
4486            let r = 0u32..=last_row;
4487            // apply_substitute moves cursor to last changed line and pushes
4488            // one undo snapshot — same semantics as `:&&` / `:%s//~/&`.
4489            let _ = crate::substitute::apply_substitute(ed, &cmd, r);
4490            // Update stored substitute so subsequent `g&` sees the same cmd.
4491            // (apply_substitute doesn't call set_last_substitute itself.)
4492            ed.vim.last_substitute = Some(cmd);
4493        }
4494        _ => {}
4495    }
4496}
4497
4498/// Normal-mode `&` — repeat the last `:s` on the current line, dropping the
4499/// previous flags (vim: `&` ≡ `:s` with no flags). `g&` keeps flags + whole
4500/// buffer; this is the single-line, flag-less form.
4501pub(crate) fn ampersand_repeat<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
4502    let Some(mut cmd) = ed.vim.last_substitute.clone() else {
4503        return;
4504    };
4505    cmd.flags = crate::substitute::SubstFlags::default();
4506    let row = buf_cursor_pos(&ed.buffer).row as u32;
4507    let _ = crate::substitute::apply_substitute(ed, &cmd, row..=row);
4508}
4509
4510/// Public(crate) entry point for bare `z<x>`. Applies the z-chord effect
4511/// given the char `ch` and pre-captured `count`. Called by `Editor::after_z`
4512/// (the public controller API) so the hjkl-vim pending-state reducer can
4513/// dispatch `AfterZChord` without re-entering the engine FSM.
4514pub(crate) fn apply_after_z<H: crate::types::Host>(
4515    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4516    ch: char,
4517    count: usize,
4518) {
4519    use crate::editor::CursorScrollTarget;
4520    let row = ed.cursor().0;
4521    match ch {
4522        'z' => {
4523            ed.scroll_cursor_to(CursorScrollTarget::Center);
4524            ed.vim.viewport_pinned = true;
4525            ed.vim.scroll_anim_hint = true;
4526        }
4527        't' => {
4528            ed.scroll_cursor_to(CursorScrollTarget::Top);
4529            ed.vim.viewport_pinned = true;
4530            ed.vim.scroll_anim_hint = true;
4531        }
4532        'b' => {
4533            ed.scroll_cursor_to(CursorScrollTarget::Bottom);
4534            ed.vim.viewport_pinned = true;
4535            ed.vim.scroll_anim_hint = true;
4536        }
4537        // Folds — operate on the fold under the cursor (or the
4538        // whole buffer for `R` / `M`). Routed through
4539        // [`Editor::apply_fold_op`] (0.0.38 Patch C-δ.4) so the host
4540        // can observe / veto each op via [`Editor::take_fold_ops`].
4541        'o' => {
4542            ed.apply_fold_op(crate::types::FoldOp::OpenAt(row));
4543        }
4544        'c' => {
4545            ed.apply_fold_op(crate::types::FoldOp::CloseAt(row));
4546        }
4547        'a' => {
4548            ed.apply_fold_op(crate::types::FoldOp::ToggleAt(row));
4549        }
4550        'R' => {
4551            ed.apply_fold_op(crate::types::FoldOp::OpenAll);
4552        }
4553        'M' => {
4554            ed.apply_fold_op(crate::types::FoldOp::CloseAll);
4555        }
4556        'E' => {
4557            ed.apply_fold_op(crate::types::FoldOp::ClearAll);
4558        }
4559        'd' => {
4560            ed.apply_fold_op(crate::types::FoldOp::RemoveAt(row));
4561        }
4562        'f' => {
4563            if matches!(
4564                ed.vim.mode,
4565                Mode::Visual | Mode::VisualLine | Mode::VisualBlock
4566            ) {
4567                // `zf` over a Visual selection creates a fold spanning
4568                // anchor → cursor.
4569                let anchor_row = match ed.vim.mode {
4570                    Mode::VisualLine => ed.vim.visual_line_anchor,
4571                    Mode::VisualBlock => ed.vim.block_anchor.0,
4572                    _ => ed.vim.visual_anchor.0,
4573                };
4574                let cur = ed.cursor().0;
4575                let top = anchor_row.min(cur);
4576                let bot = anchor_row.max(cur);
4577                ed.apply_fold_op(crate::types::FoldOp::Add {
4578                    start_row: top,
4579                    end_row: bot,
4580                    closed: true,
4581                });
4582                ed.vim.mode = Mode::Normal;
4583            } else {
4584                // `zf{motion}` / `zf{textobj}` — route through the
4585                // operator pipeline. `Operator::Fold` reuses every
4586                // motion / text-object / `g`-prefix branch the other
4587                // operators get.
4588                ed.vim.pending = Pending::Op {
4589                    op: Operator::Fold,
4590                    count1: count,
4591                };
4592            }
4593        }
4594        _ => {}
4595    }
4596}
4597
4598/// Public(crate) entry point for bare `f<x>` / `F<x>` / `t<x>` / `T<x>`.
4599/// Applies the motion and records `last_find` for `;` / `,` repeat.
4600/// Called by `Editor::find_char` (the public controller API) so the
4601/// hjkl-vim pending-state reducer can dispatch `FindChar` without
4602/// re-entering the FSM.
4603pub(crate) fn apply_find_char<H: crate::types::Host>(
4604    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4605    ch: char,
4606    forward: bool,
4607    till: bool,
4608    count: usize,
4609) {
4610    execute_motion(ed, Motion::Find { ch, forward, till }, count.max(1));
4611    ed.vim.last_find = Some((ch, forward, till));
4612    ed.vim.last_horizontal_motion = LastHorizontalMotion::FindChar;
4613}
4614
4615// ─── Sneak motion ──────────────────────────────────────────────────────────
4616
4617/// Scan the buffer from the current cursor position for the `count`-th
4618/// occurrence of the two-char digraph `(c1, c2)`.
4619///
4620/// - `forward=true` → scan downward (rows) and rightward (cols) past cursor.
4621/// - `forward=false` → scan upward and leftward.
4622///
4623/// When a match is found the cursor jumps to the first char of the digraph.
4624/// `last_sneak` and `last_horizontal_motion` are updated so `;`/`,` repeat.
4625/// No-op (cursor unchanged) when no match exists.
4626pub(crate) fn apply_sneak<H: crate::types::Host>(
4627    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4628    c1: char,
4629    c2: char,
4630    forward: bool,
4631    count: usize,
4632) {
4633    let count = count.max(1);
4634    let (start_row, start_col) = ed.cursor();
4635    let row_count = buf_row_count(&ed.buffer);
4636
4637    let result = if forward {
4638        sneak_scan_forward(ed, start_row, start_col, c1, c2, count)
4639    } else {
4640        sneak_scan_backward(ed, start_row, start_col, c1, c2, count)
4641    };
4642
4643    if let Some((row, col)) = result {
4644        buf_set_cursor_rc(&mut ed.buffer, row, col);
4645        ed.push_buffer_cursor_to_textarea();
4646        let _ = row_count; // suppress unused-variable warning
4647    }
4648
4649    ed.vim.last_sneak = Some(((c1, c2), forward));
4650    ed.vim.last_horizontal_motion = LastHorizontalMotion::Sneak;
4651}
4652
4653/// Scan forward from `(start_row, start_col)` (exclusive — start right after
4654/// cursor) for the `count`-th occurrence of `c1+c2`.
4655fn sneak_scan_forward<H: crate::types::Host>(
4656    ed: &Editor<hjkl_buffer::Buffer, H>,
4657    start_row: usize,
4658    start_col: usize,
4659    c1: char,
4660    c2: char,
4661    count: usize,
4662) -> Option<(usize, usize)> {
4663    let row_count = buf_row_count(&ed.buffer);
4664    let mut hits = 0usize;
4665    for row in start_row..row_count {
4666        let line = buf_line(&ed.buffer, row).unwrap_or_default();
4667        let chars: Vec<char> = line.chars().collect();
4668        // On the start row begin scanning one past the current column.
4669        let col_start = if row == start_row { start_col + 1 } else { 0 };
4670        if col_start + 1 > chars.len() {
4671            continue;
4672        }
4673        for col in col_start..chars.len().saturating_sub(1) {
4674            if chars[col] == c1 && chars[col + 1] == c2 {
4675                hits += 1;
4676                if hits == count {
4677                    return Some((row, col));
4678                }
4679            }
4680        }
4681    }
4682    None
4683}
4684
4685/// Scan backward from `(start_row, start_col)` (exclusive — start left of
4686/// cursor) for the `count`-th occurrence of `c1+c2`.
4687fn sneak_scan_backward<H: crate::types::Host>(
4688    ed: &Editor<hjkl_buffer::Buffer, H>,
4689    start_row: usize,
4690    start_col: usize,
4691    c1: char,
4692    c2: char,
4693    count: usize,
4694) -> Option<(usize, usize)> {
4695    let row_count = buf_row_count(&ed.buffer);
4696    let mut hits = 0usize;
4697    // Iterate rows from start_row down to 0.
4698    let rows_to_scan = (0..row_count).rev().skip(row_count - start_row - 1);
4699    for row in rows_to_scan {
4700        let line = buf_line(&ed.buffer, row).unwrap_or_default();
4701        let chars: Vec<char> = line.chars().collect();
4702        // On the start row end scanning one before the current column.
4703        let col_end = if row == start_row {
4704            start_col.saturating_sub(1)
4705        } else if chars.is_empty() {
4706            continue;
4707        } else {
4708            chars.len().saturating_sub(1)
4709        };
4710        if col_end == 0 {
4711            continue;
4712        }
4713        // Scan cols right-to-left from col_end-1 so we match c1 at col, c2 at col+1.
4714        for col in (0..col_end).rev() {
4715            if col + 1 < chars.len() && chars[col] == c1 && chars[col + 1] == c2 {
4716                hits += 1;
4717                if hits == count {
4718                    return Some((row, col));
4719                }
4720            }
4721        }
4722    }
4723    None
4724}
4725
4726/// Apply `op` over the sneak digraph range. Charwise exclusive from cursor up
4727/// to (but not including) the first char of the first match. This matches
4728/// vim-sneak's default `<Plug>Sneak_s` operator-pending behavior.
4729///
4730/// Example: buffer `"foo ab bar\n"`, cursor col 0, `dsab` → deletes `"foo "`
4731/// leaving `"ab bar\n"`.
4732pub(crate) fn apply_op_sneak<H: crate::types::Host>(
4733    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4734    op: Operator,
4735    c1: char,
4736    c2: char,
4737    forward: bool,
4738    total_count: usize,
4739) {
4740    let start = ed.cursor();
4741    let result = if forward {
4742        sneak_scan_forward(ed, start.0, start.1, c1, c2, total_count)
4743    } else {
4744        sneak_scan_backward(ed, start.0, start.1, c1, c2, total_count)
4745    };
4746    let Some(end) = result else {
4747        return;
4748    };
4749    // Charwise exclusive — land the virtual cursor at end, then use
4750    // Exclusive range kind (end position not included).
4751    ed.jump_cursor(end.0, end.1);
4752    let end_cur = ed.cursor();
4753    ed.jump_cursor(start.0, start.1);
4754    run_operator_over_range(ed, op, start, end_cur, RangeKind::Exclusive);
4755    ed.vim.last_sneak = Some(((c1, c2), forward));
4756    ed.vim.last_horizontal_motion = LastHorizontalMotion::Sneak;
4757    if !ed.vim.replaying && op_is_change(op) {
4758        // No dot-repeat motion variant for sneak ops (plugin behavior,
4759        // not vim-core); record as a Change/Delete line op as a
4760        // best-effort fallback so `.` at least does something.
4761    }
4762}
4763
4764/// Public(crate) entry: apply operator over a find motion (`df<x>` etc.).
4765/// Called by `Editor::apply_op_find` (the public controller API) so the
4766/// hjkl-vim `PendingState::OpFind` reducer can dispatch `ApplyOpFind` without
4767/// re-entering the FSM. `handle_op_find_target` now delegates here to avoid
4768/// logic duplication.
4769pub(crate) fn apply_op_find_motion<H: crate::types::Host>(
4770    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4771    op: Operator,
4772    ch: char,
4773    forward: bool,
4774    till: bool,
4775    total_count: usize,
4776) {
4777    let motion = Motion::Find { ch, forward, till };
4778    apply_op_with_motion(ed, op, &motion, total_count);
4779    ed.vim.last_find = Some((ch, forward, till));
4780    if !ed.vim.replaying && op_is_change(op) {
4781        ed.vim.last_change = Some(LastChange::OpMotion {
4782            op,
4783            motion,
4784            count: total_count,
4785            inserted: None,
4786        });
4787    }
4788}
4789
4790/// Shared implementation: map `ch` to `TextObject`, apply the operator, and
4791/// record `last_change`. Returns `false` when `ch` is not a known text-object
4792/// kind (caller should treat as a no-op). Called by `Editor::apply_op_text_obj`
4793/// (the public controller API) so hjkl-vim can dispatch without re-entering the FSM.
4794///
4795/// `_total_count` is accepted for API symmetry with `apply_op_find_motion` /
4796/// `apply_op_motion_key` but is currently unused — text objects don't repeat
4797/// in vim's current grammar. Kept for future-proofing.
4798pub(crate) fn apply_op_text_obj_inner<H: crate::types::Host>(
4799    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4800    op: Operator,
4801    ch: char,
4802    inner: bool,
4803    total_count: usize,
4804) -> bool {
4805    // `total_count` drives bracket text objects: `2di{` targets the Nth
4806    // enclosing pair. Non-bracket objects ignore it (vim does too).
4807    let obj = match ch {
4808        'w' => TextObject::Word { big: false },
4809        'W' => TextObject::Word { big: true },
4810        '"' | '\'' | '`' => TextObject::Quote(ch),
4811        '(' | ')' | 'b' => TextObject::Bracket('('),
4812        '[' | ']' => TextObject::Bracket('['),
4813        '{' | '}' | 'B' => TextObject::Bracket('{'),
4814        '<' | '>' => TextObject::Bracket('<'),
4815        'p' => TextObject::Paragraph,
4816        't' => TextObject::XmlTag,
4817        's' => TextObject::Sentence,
4818        _ => return false,
4819    };
4820    apply_op_with_text_object(ed, op, obj, inner, total_count.max(1));
4821    if !ed.vim.replaying && op_is_change(op) {
4822        ed.vim.last_change = Some(LastChange::OpTextObj {
4823            op,
4824            obj,
4825            inner,
4826            inserted: None,
4827        });
4828    }
4829    true
4830}
4831
4832/// Move `pos` back by one character, clamped to (0, 0).
4833pub(crate) fn retreat_one<H: crate::types::Host>(
4834    ed: &Editor<hjkl_buffer::Buffer, H>,
4835    pos: (usize, usize),
4836) -> (usize, usize) {
4837    let (r, c) = pos;
4838    if c > 0 {
4839        (r, c - 1)
4840    } else if r > 0 {
4841        let prev_len = buf_line_bytes(&ed.buffer, r - 1);
4842        (r - 1, prev_len)
4843    } else {
4844        (0, 0)
4845    }
4846}
4847
4848/// Variant of begin_insert that doesn't push_undo (caller already did).
4849fn begin_insert_noundo<H: crate::types::Host>(
4850    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4851    count: usize,
4852    reason: InsertReason,
4853) {
4854    let reason = if ed.vim.replaying {
4855        InsertReason::ReplayOnly
4856    } else {
4857        reason
4858    };
4859    let (row, col) = ed.cursor();
4860    ed.vim.insert_session = Some(InsertSession {
4861        count,
4862        row_min: row,
4863        row_max: row,
4864        before_rope: crate::types::Query::rope(&ed.buffer),
4865        reason,
4866        start_row: row,
4867        start_col: col,
4868    });
4869    ed.vim.mode = Mode::Insert;
4870    // Phase 6.3: keep current_mode in sync for callers that bypass step().
4871    ed.vim.current_mode = crate::VimMode::Insert;
4872    drop_blame_if_left_normal(ed);
4873}
4874
4875// ─── Operator × Motion application ─────────────────────────────────────────
4876
4877pub(crate) fn apply_op_with_motion<H: crate::types::Host>(
4878    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4879    op: Operator,
4880    motion: &Motion,
4881    count: usize,
4882) {
4883    let start = ed.cursor();
4884    // Tentatively apply motion to find the endpoint. Operator context
4885    // so `l` on the last char advances past-last (standard vim
4886    // exclusive-motion endpoint behaviour), enabling `dl` / `cl` /
4887    // `yl` to cover the final char.
4888    apply_motion_cursor_ctx(ed, motion, count, true);
4889    let mut end = ed.cursor();
4890    let mut kind = motion_kind(motion);
4891    // Vim special case (`:h word`): when `w`/`W` is used with an operator and
4892    // the last word moved over ends its line, the operated text stops at the
4893    // end of that word instead of eating the line break into the next line's
4894    // first word. `d2w` that ends mid-line on a later row is unaffected, since
4895    // its last word is not at end-of-line. When the word-forward motion
4896    // crossed onto a later row, clamp `end` back to the last non-blank char it
4897    // moved over and make the range inclusive.
4898    if matches!(motion, Motion::WordFwd | Motion::BigWordFwd)
4899        && kind == RangeKind::Exclusive
4900        && end.0 > start.0
4901        && let Some(word_end) = last_word_end_before(ed, start, end)
4902        && word_end.0 < end.0
4903    {
4904        end = word_end;
4905        kind = RangeKind::Inclusive;
4906    }
4907    // Restore cursor before selecting (so Yank leaves cursor at start).
4908    ed.jump_cursor(start.0, start.1);
4909
4910    // Comment is always linewise regardless of motion kind — toggle rows.
4911    if op == Operator::Comment {
4912        let top = start.0.min(end.0);
4913        let bot = start.0.max(end.0);
4914        ed.toggle_comment_range(top, bot);
4915        ed.vim.mode = Mode::Normal;
4916        return;
4917    }
4918
4919    run_operator_over_range(ed, op, start, end, kind);
4920}
4921
4922/// Position of the last non-blank char in the half-open range `[start, end)`,
4923/// scanning rows from the bottom up. Used to clamp `dw`/`dW` at end-of-line
4924/// (vim's `:h word` special case): the returned position is the end of the
4925/// last word moved over. For a counted `d{n}w` the last word can sit on the
4926/// landing row itself (before `end.1`), so that row is scanned too — only
4927/// columns `[.., end.1)` there — which is what keeps `d2w` ending mid-line
4928/// (last word not at EOL, `word_end.0 == end.0`) from being clamped.
4929fn last_word_end_before<H: crate::types::Host>(
4930    ed: &Editor<hjkl_buffer::Buffer, H>,
4931    start: (usize, usize),
4932    end: (usize, usize),
4933) -> Option<(usize, usize)> {
4934    for r in (start.0..=end.0).rev() {
4935        let line = buf_line(&ed.buffer, r).unwrap_or_default();
4936        let lo = if r == start.0 { start.1 } else { 0 };
4937        let hi = if r == end.0 {
4938            end.1
4939        } else {
4940            line.chars().count()
4941        };
4942        let last = line
4943            .chars()
4944            .enumerate()
4945            .filter(|(i, ch)| *i >= lo && *i < hi && *ch != ' ' && *ch != '\t')
4946            .map(|(i, _)| i)
4947            .last();
4948        if let Some(col) = last {
4949            return Some((r, col));
4950        }
4951    }
4952    None
4953}
4954
4955fn apply_op_with_text_object<H: crate::types::Host>(
4956    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4957    op: Operator,
4958    obj: TextObject,
4959    inner: bool,
4960    count: usize,
4961) {
4962    // Folded counts can exceed a single prefix's cap; re-clamp at vim's
4963    // count ceiling (`:h count`).
4964    let count = count.min(MAX_COUNT);
4965    let Some((mut start, mut end, mut kind)) = text_object_range(ed, obj, inner, count) else {
4966        return;
4967    };
4968    // vim's exclusive-motion adjustment (`:h exclusive`), applied to the
4969    // OPERATOR form of an inner bracket object spanning multiple lines (the
4970    // visual form keeps the raw charwise region). When the exclusive end sits
4971    // in column 0, pull it back to the end of the previous line and make the
4972    // motion inclusive; if the start is at or before the first non-blank of its
4973    // line, promote to linewise. This is what makes `di{` on a contentful
4974    // multi-line block collapse to bare braces ("{\n}") and a clean block
4975    // delete its body linewise.
4976    if inner
4977        && matches!(obj, TextObject::Bracket(_))
4978        && kind == RangeKind::Exclusive
4979        && end.0 > start.0
4980        && end.1 == 0
4981    {
4982        let prev = end.0 - 1;
4983        let prev_len = buf_line_chars(&ed.buffer, prev);
4984        let fnb = buf_line(&ed.buffer, start.0)
4985            .unwrap_or_default()
4986            .chars()
4987            .take_while(|c| *c == ' ' || *c == '\t')
4988            .count();
4989        if start.1 <= fnb {
4990            start = (start.0, 0);
4991            end = (prev, prev_len);
4992            kind = RangeKind::Linewise;
4993        } else {
4994            end = (prev, prev_len.saturating_sub(1));
4995            kind = RangeKind::Inclusive;
4996        }
4997    }
4998    ed.jump_cursor(start.0, start.1);
4999    run_operator_over_range(ed, op, start, end, kind);
5000}
5001
5002fn motion_kind(motion: &Motion) -> RangeKind {
5003    match motion {
5004        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown => RangeKind::Linewise,
5005        Motion::FileTop | Motion::FileBottom => RangeKind::Linewise,
5006        Motion::ViewportTop | Motion::ViewportMiddle | Motion::ViewportBottom => {
5007            RangeKind::Linewise
5008        }
5009        Motion::WordEnd | Motion::BigWordEnd | Motion::WordEndBack | Motion::BigWordEndBack => {
5010            RangeKind::Inclusive
5011        }
5012        Motion::Find { .. } => RangeKind::Inclusive,
5013        Motion::MatchBracket => RangeKind::Inclusive,
5014        // `[(` / `])` etc. are exclusive: `d])` deletes up to but not including
5015        // the bracket; `d[(` deletes back to but not past the open bracket.
5016        Motion::UnmatchedBracket { .. } => RangeKind::Exclusive,
5017        // `$` now lands on the last char — operator ranges include it.
5018        Motion::LineEnd => RangeKind::Inclusive,
5019        // Linewise motions: +/-/_ land on the first non-blank of a line.
5020        Motion::FirstNonBlankNextLine
5021        | Motion::FirstNonBlankPrevLine
5022        | Motion::FirstNonBlankLine => RangeKind::Linewise,
5023        // [[/]]/[][/][ are charwise exclusive (land on the brace, brace excluded from operator).
5024        Motion::SectionBackward
5025        | Motion::SectionForward
5026        | Motion::SectionEndBackward
5027        | Motion::SectionEndForward => RangeKind::Exclusive,
5028        _ => RangeKind::Exclusive,
5029    }
5030}
5031
5032/// Linewise change of rows `[top_row, end_row]` (vim `cc`/`cj`/`Vc`/`cip`…).
5033///
5034/// Deletes the spanned lines, leaves one line carrying the first row's
5035/// leading whitespace (when `autoindent` is on), parks the cursor after
5036/// the indent, and enters insert mode. Records the full linewise payload
5037/// to the yank + delete registers and sets `change_mark_start` for the
5038/// `[`/`]` deferral. Calls `push_undo` internally — callers must NOT also
5039/// call it.
5040fn change_linewise_rows<H: crate::types::Host>(
5041    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5042    top_row: usize,
5043    end_row: usize,
5044) {
5045    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
5046    // Vim `:h '[`: stash change start for `]` deferral on insert-exit.
5047    ed.vim.change_mark_start = Some((top_row, 0));
5048    ed.push_undo();
5049    ed.sync_buffer_content_from_textarea();
5050    // Read the cut payload first so yank reflects every original line.
5051    let payload = read_vim_range(ed, (top_row, 0), (end_row, 0), RangeKind::Linewise);
5052    // Drop every row after the first (rows [top_row+1, end_row]).
5053    if end_row > top_row {
5054        ed.mutate_edit(Edit::DeleteRange {
5055            start: Position::new(top_row + 1, 0),
5056            end: Position::new(end_row, 0),
5057            kind: BufKind::Line,
5058        });
5059    }
5060    // Preserve the first row's leading whitespace when autoindent is on;
5061    // wipe the whole line content otherwise (cursor lands at col 0).
5062    let indent_chars = if ed.settings.autoindent {
5063        let line = hjkl_buffer::rope_line_str(&crate::types::Query::rope(&ed.buffer), top_row);
5064        line.chars().take_while(|c| *c == ' ' || *c == '\t').count()
5065    } else {
5066        0
5067    };
5068    let line_chars = buf_line_chars(&ed.buffer, top_row);
5069    if line_chars > indent_chars {
5070        ed.mutate_edit(Edit::DeleteRange {
5071            start: Position::new(top_row, indent_chars),
5072            end: Position::new(top_row, line_chars),
5073            kind: BufKind::Char,
5074        });
5075    }
5076    if !payload.is_empty() {
5077        ed.record_yank_to_host(payload.clone());
5078        ed.record_delete(payload, true);
5079    }
5080    buf_set_cursor_rc(&mut ed.buffer, top_row, indent_chars);
5081    ed.push_buffer_cursor_to_textarea();
5082    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
5083}
5084
5085fn run_operator_over_range<H: crate::types::Host>(
5086    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5087    op: Operator,
5088    start: (usize, usize),
5089    end: (usize, usize),
5090    kind: RangeKind,
5091) {
5092    let (top, bot) = order(start, end);
5093    // Charwise empty range (same position). For Delete/Yank there is nothing to
5094    // act on. For Change, vim still enters insert at that point — `ci(` on `()`
5095    // and `ci{` on a whitespace-only block both place the cursor inside and
5096    // start inserting without deleting anything.
5097    if top == bot && !matches!(kind, RangeKind::Linewise) {
5098        if op == Operator::Change {
5099            ed.vim.change_mark_start = Some(top);
5100            ed.push_undo();
5101            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
5102        }
5103        return;
5104    }
5105
5106    match op {
5107        Operator::Yank => {
5108            let text = read_vim_range(ed, top, bot, kind);
5109            if !text.is_empty() {
5110                ed.record_yank_to_host(text.clone());
5111                ed.record_yank(text, matches!(kind, RangeKind::Linewise));
5112            }
5113            // Vim `:h '[` / `:h ']`: after a yank `[` = first yanked char,
5114            // `]` = last yanked char. Mode-aware: linewise snaps to line
5115            // edges; charwise uses the actual inclusive endpoint.
5116            let rbr = match kind {
5117                RangeKind::Linewise => {
5118                    let last_col = buf_line_chars(&ed.buffer, bot.0).saturating_sub(1);
5119                    (bot.0, last_col)
5120                }
5121                RangeKind::Inclusive => (bot.0, bot.1),
5122                RangeKind::Exclusive => (bot.0, bot.1.saturating_sub(1)),
5123            };
5124            ed.set_mark('[', top);
5125            ed.set_mark(']', rbr);
5126            buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
5127            ed.push_buffer_cursor_to_textarea();
5128        }
5129        Operator::Delete => {
5130            ed.push_undo();
5131            cut_vim_range(ed, top, bot, kind);
5132            // After a charwise / inclusive delete the buffer cursor is
5133            // placed at `start` by the edit path. In Normal mode the
5134            // cursor max col is `line_len - 1`; clamp it here so e.g.
5135            // `d$` doesn't leave the cursor one past the new line end.
5136            if !matches!(kind, RangeKind::Linewise) {
5137                clamp_cursor_to_normal_mode(ed);
5138            }
5139            ed.vim.mode = Mode::Normal;
5140            // Vim `:h '[` / `:h ']`: after a delete both marks park at
5141            // the cursor position where the deletion collapsed (the join
5142            // point). Set after the cut and clamp so the position is final.
5143            let pos = ed.cursor();
5144            ed.set_mark('[', pos);
5145            ed.set_mark(']', pos);
5146        }
5147        Operator::Change => {
5148            // Vim `:h '[`: `[` is set to the start of the changed range
5149            // before the cut. `]` is deferred to insert-exit (AfterChange
5150            // path in finish_insert_session) where the cursor sits on the
5151            // last inserted char.
5152            if matches!(kind, RangeKind::Linewise) {
5153                // Linewise change (`cj`/`ck`/`cip`/`cap`/…): preserve the
5154                // first line's indent and leave exactly one row open for
5155                // insert. The helper handles push_undo + insert entry.
5156                change_linewise_rows(ed, top.0, bot.0);
5157            } else {
5158                // Charwise change: cut the range and enter insert.
5159                ed.vim.change_mark_start = Some(top);
5160                ed.push_undo();
5161                cut_vim_range(ed, top, bot, kind);
5162                begin_insert_noundo(ed, 1, InsertReason::AfterChange);
5163            }
5164        }
5165        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {
5166            apply_case_op_to_selection(ed, op, top, bot, kind);
5167        }
5168        Operator::Indent | Operator::Outdent => {
5169            // Indent / outdent are always linewise even when triggered
5170            // by a char-wise motion (e.g. `>w` indents the whole line).
5171            ed.push_undo();
5172            if op == Operator::Indent {
5173                indent_rows(ed, top.0, bot.0, 1);
5174            } else {
5175                outdent_rows(ed, top.0, bot.0, 1);
5176            }
5177            ed.vim.mode = Mode::Normal;
5178        }
5179        Operator::Fold => {
5180            // Always linewise — fold the spanned rows regardless of the
5181            // motion's natural kind. Cursor lands on `top.0` to mirror
5182            // the visual `zf` path.
5183            if bot.0 >= top.0 {
5184                ed.apply_fold_op(crate::types::FoldOp::Add {
5185                    start_row: top.0,
5186                    end_row: bot.0,
5187                    closed: true,
5188                });
5189            }
5190            buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
5191            ed.push_buffer_cursor_to_textarea();
5192            ed.vim.mode = Mode::Normal;
5193        }
5194        Operator::Reflow => {
5195            ed.push_undo();
5196            reflow_rows(ed, top.0, bot.0);
5197            ed.vim.mode = Mode::Normal;
5198        }
5199        Operator::ReflowKeepCursor => {
5200            // `gw{motion}` — reflow like `gq` but restore the cursor to the
5201            // character it was on before the reflow (vim's gw behaviour).
5202            let saved = ed.cursor();
5203            ed.push_undo();
5204            let (before, after) = reflow_rows_keep_cursor(ed, top.0, bot.0);
5205            let (new_row, new_col) = reflow_keep_cursor(top.0, saved.0, saved.1, &before, &after);
5206            buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
5207            ed.push_buffer_cursor_to_textarea();
5208            ed.sticky_col = Some(new_col);
5209            ed.vim.mode = Mode::Normal;
5210        }
5211        Operator::AutoIndent => {
5212            // Always linewise — like Indent/Outdent.
5213            ed.push_undo();
5214            auto_indent_rows(ed, top.0, bot.0);
5215            ed.vim.mode = Mode::Normal;
5216        }
5217        Operator::Filter => {
5218            // Filter is not dispatched through run_operator_over_range.
5219            // The app calls Editor::filter_range directly with a command string.
5220            // Reaching this arm means a caller invoked run_operator_over_range
5221            // with Operator::Filter by mistake — silently no-op.
5222        }
5223        Operator::Comment => {
5224            // Comment is dispatched through Editor::toggle_comment_range.
5225            // Reaching this arm is a caller mistake — silently no-op.
5226        }
5227    }
5228}
5229
5230// ─── Phase 4a pub range-mutation bridges ───────────────────────────────────
5231//
5232// These are `pub(crate)` entry points called by the five new pub methods on
5233// `Editor` (`delete_range`, `yank_range`, `change_range`, `indent_range`,
5234// `case_range`). They set `pending_register` from the caller-supplied char
5235// before delegating to the existing internal helpers so register semantics
5236// (unnamed `"`, named `"a`–`"z`, delete ring) are honoured exactly as in the
5237// FSM path.
5238//
5239// Do NOT call `run_operator_over_range` for Indent/Outdent or the three case
5240// operators — those share the FSM path but have dedicated parameter shapes
5241// (signed count, Operator-as-CaseOp) that map more cleanly to their own
5242// helpers.
5243
5244/// Delete the range `[start, end)` (interpretation determined by `kind`) and
5245/// stash the deleted text in `register`. `'"'` is the unnamed register.
5246pub(crate) fn delete_range_bridge<H: crate::types::Host>(
5247    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5248    start: (usize, usize),
5249    end: (usize, usize),
5250    kind: RangeKind,
5251    register: char,
5252) {
5253    ed.vim.pending_register = Some(register);
5254    run_operator_over_range(ed, Operator::Delete, start, end, kind);
5255}
5256
5257/// Yank (copy) the range `[start, end)` into `register` without mutating the
5258/// buffer. `'"'` is the unnamed register.
5259pub(crate) fn yank_range_bridge<H: crate::types::Host>(
5260    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5261    start: (usize, usize),
5262    end: (usize, usize),
5263    kind: RangeKind,
5264    register: char,
5265) {
5266    ed.vim.pending_register = Some(register);
5267    run_operator_over_range(ed, Operator::Yank, start, end, kind);
5268}
5269
5270/// Delete the range `[start, end)` and enter Insert mode (vim `c` operator).
5271/// The deleted text is stashed in `register`. Mode transitions to Insert on
5272/// return; the caller must not issue further normal-mode ops until the insert
5273/// session ends.
5274pub(crate) fn change_range_bridge<H: crate::types::Host>(
5275    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5276    start: (usize, usize),
5277    end: (usize, usize),
5278    kind: RangeKind,
5279    register: char,
5280) {
5281    ed.vim.pending_register = Some(register);
5282    run_operator_over_range(ed, Operator::Change, start, end, kind);
5283}
5284
5285/// Indent (`count > 0`) or outdent (`count < 0`) the row span `[start.0,
5286/// end.0]`. `shiftwidth` overrides the editor's `settings().shiftwidth` for
5287/// this call; pass `0` to use the editor setting. The column parts of `start`
5288/// / `end` are ignored — indent is always linewise.
5289pub(crate) fn indent_range_bridge<H: crate::types::Host>(
5290    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5291    start: (usize, usize),
5292    end: (usize, usize),
5293    count: i32,
5294    shiftwidth: u32,
5295) {
5296    if count == 0 {
5297        return;
5298    }
5299    let (top_row, bot_row) = if start.0 <= end.0 {
5300        (start.0, end.0)
5301    } else {
5302        (end.0, start.0)
5303    };
5304    // Temporarily override shiftwidth when the caller provides one.
5305    let original_sw = ed.settings().shiftwidth;
5306    if shiftwidth > 0 {
5307        ed.settings_mut().shiftwidth = shiftwidth as usize;
5308    }
5309    ed.push_undo();
5310    let abs_count = count.unsigned_abs() as usize;
5311    if count > 0 {
5312        indent_rows(ed, top_row, bot_row, abs_count);
5313    } else {
5314        outdent_rows(ed, top_row, bot_row, abs_count);
5315    }
5316    if shiftwidth > 0 {
5317        ed.settings_mut().shiftwidth = original_sw;
5318    }
5319    ed.vim.mode = Mode::Normal;
5320}
5321
5322/// Apply a case transformation (`Uppercase` / `Lowercase` / `ToggleCase`) to
5323/// the range `[start, end)`. Only the three case `Operator` variants are valid;
5324/// other variants are silently ignored (no-op).
5325pub(crate) fn case_range_bridge<H: crate::types::Host>(
5326    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5327    start: (usize, usize),
5328    end: (usize, usize),
5329    kind: RangeKind,
5330    op: Operator,
5331) {
5332    match op {
5333        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {}
5334        _ => return,
5335    }
5336    let (top, bot) = order(start, end);
5337    apply_case_op_to_selection(ed, op, top, bot, kind);
5338}
5339
5340// ─── Phase 4e pub block-shape range-mutation bridges ───────────────────────
5341//
5342// These are `pub(crate)` entry points called by the four new pub methods on
5343// `Editor` (`delete_block`, `yank_block`, `change_block`, `indent_block`).
5344// They set `pending_register` from the caller-supplied char then delegate to
5345// `apply_block_operator` (after temporarily installing the 4-corner block as
5346// the engine's virtual VisualBlock selection). The editor's VisualBlock state
5347// fields (`block_anchor`, `block_vcol`) are overwritten, the op fires, then
5348// the fields are restored to their pre-call values. This ensures the engine's
5349// register / undo / mode semantics are exercised without requiring the caller
5350// to already be in VisualBlock mode.
5351//
5352// `indent_block` is a separate helper — it does not use `apply_block_operator`
5353// because indent/outdent are always linewise for blocks (vim behaviour).
5354
5355/// Delete a rectangular VisualBlock selection. `top_row`/`bot_row` are
5356/// inclusive line bounds; `left_col`/`right_col` are inclusive char-column
5357/// bounds. Short lines that don't reach `right_col` lose only the chars
5358/// that exist (ragged-edge, matching engine FSM). `register` is honoured;
5359/// `'"'` selects the unnamed register.
5360pub(crate) fn delete_block_bridge<H: crate::types::Host>(
5361    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5362    top_row: usize,
5363    bot_row: usize,
5364    left_col: usize,
5365    right_col: usize,
5366    register: char,
5367) {
5368    ed.vim.pending_register = Some(register);
5369    let saved_anchor = ed.vim.block_anchor;
5370    let saved_vcol = ed.vim.block_vcol;
5371    ed.vim.block_anchor = (top_row, left_col);
5372    ed.vim.block_vcol = right_col;
5373    // Compute clamped col before the mutable borrow for buf_set_cursor_rc.
5374    let clamped = right_col.min(buf_line_chars(&ed.buffer, bot_row).saturating_sub(1));
5375    // Place cursor at bot_row / right_col so block_bounds resolves correctly.
5376    buf_set_cursor_rc(&mut ed.buffer, bot_row, clamped);
5377    apply_block_operator(ed, Operator::Delete, 1);
5378    // Restore — block_anchor/vcol are only meaningful in VisualBlock mode;
5379    // after the op we're in Normal so restoring is a no-op for the user but
5380    // keeps state coherent if the caller inspects fields.
5381    ed.vim.block_anchor = saved_anchor;
5382    ed.vim.block_vcol = saved_vcol;
5383}
5384
5385/// Yank a rectangular VisualBlock selection into `register`.
5386pub(crate) fn yank_block_bridge<H: crate::types::Host>(
5387    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5388    top_row: usize,
5389    bot_row: usize,
5390    left_col: usize,
5391    right_col: usize,
5392    register: char,
5393) {
5394    ed.vim.pending_register = Some(register);
5395    let saved_anchor = ed.vim.block_anchor;
5396    let saved_vcol = ed.vim.block_vcol;
5397    ed.vim.block_anchor = (top_row, left_col);
5398    ed.vim.block_vcol = right_col;
5399    let clamped = right_col.min(buf_line_chars(&ed.buffer, bot_row).saturating_sub(1));
5400    buf_set_cursor_rc(&mut ed.buffer, bot_row, clamped);
5401    apply_block_operator(ed, Operator::Yank, 1);
5402    ed.vim.block_anchor = saved_anchor;
5403    ed.vim.block_vcol = saved_vcol;
5404}
5405
5406/// Delete a rectangular VisualBlock selection and enter Insert mode (`c`).
5407/// The deleted text is stashed in `register`. Mode is Insert on return.
5408pub(crate) fn change_block_bridge<H: crate::types::Host>(
5409    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5410    top_row: usize,
5411    bot_row: usize,
5412    left_col: usize,
5413    right_col: usize,
5414    register: char,
5415) {
5416    ed.vim.pending_register = Some(register);
5417    let saved_anchor = ed.vim.block_anchor;
5418    let saved_vcol = ed.vim.block_vcol;
5419    ed.vim.block_anchor = (top_row, left_col);
5420    ed.vim.block_vcol = right_col;
5421    let clamped = right_col.min(buf_line_chars(&ed.buffer, bot_row).saturating_sub(1));
5422    buf_set_cursor_rc(&mut ed.buffer, bot_row, clamped);
5423    apply_block_operator(ed, Operator::Change, 1);
5424    ed.vim.block_anchor = saved_anchor;
5425    ed.vim.block_vcol = saved_vcol;
5426}
5427
5428/// Indent (`count > 0`) or outdent (`count < 0`) rows `top_row..=bot_row`.
5429/// Column bounds are ignored — vim's block indent is always linewise.
5430/// `count == 0` is a no-op.
5431pub(crate) fn indent_block_bridge<H: crate::types::Host>(
5432    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5433    top_row: usize,
5434    bot_row: usize,
5435    count: i32,
5436) {
5437    if count == 0 {
5438        return;
5439    }
5440    ed.push_undo();
5441    let abs = count.unsigned_abs() as usize;
5442    if count > 0 {
5443        indent_rows(ed, top_row, bot_row, abs);
5444    } else {
5445        outdent_rows(ed, top_row, bot_row, abs);
5446    }
5447    ed.vim.mode = Mode::Normal;
5448}
5449
5450/// Auto-indent (v1 dumb shiftwidth) the row span `[start.0, end.0]`. Column
5451/// parts are ignored — auto-indent is always linewise. See
5452/// `auto_indent_rows` for the algorithm and its v1 limitations.
5453pub(crate) fn auto_indent_range_bridge<H: crate::types::Host>(
5454    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5455    start: (usize, usize),
5456    end: (usize, usize),
5457) {
5458    let (top_row, bot_row) = if start.0 <= end.0 {
5459        (start.0, end.0)
5460    } else {
5461        (end.0, start.0)
5462    };
5463    ed.push_undo();
5464    auto_indent_rows(ed, top_row, bot_row);
5465    ed.vim.mode = Mode::Normal;
5466}
5467
5468// ─── Phase 4b pub text-object resolution bridges ───────────────────────────
5469//
5470// These are `pub(crate)` entry points called by the four new pub methods on
5471// `Editor` (`text_object_inner_word`, `text_object_around_word`,
5472// `text_object_inner_big_word`, `text_object_around_big_word`). They delegate
5473// to `word_text_object` — the existing private resolver — without touching any
5474// operator, register, or mode state. Pure functions: only `&Editor` required.
5475
5476/// Resolve the range of `iw` (inner word) at the current cursor position.
5477/// Returns `None` if no word exists at the cursor.
5478pub(crate) fn text_object_inner_word_bridge<H: crate::types::Host>(
5479    ed: &Editor<hjkl_buffer::Buffer, H>,
5480) -> Option<((usize, usize), (usize, usize))> {
5481    word_text_object(ed, true, false, 1)
5482}
5483
5484/// Resolve the range of `aw` (around word) at the current cursor position.
5485/// Includes trailing whitespace (or leading whitespace if no trailing exists).
5486pub(crate) fn text_object_around_word_bridge<H: crate::types::Host>(
5487    ed: &Editor<hjkl_buffer::Buffer, H>,
5488) -> Option<((usize, usize), (usize, usize))> {
5489    word_text_object(ed, false, false, 1)
5490}
5491
5492/// Resolve the range of `iW` (inner WORD) at the current cursor position.
5493/// A WORD is any run of non-whitespace characters (no punctuation splitting).
5494pub(crate) fn text_object_inner_big_word_bridge<H: crate::types::Host>(
5495    ed: &Editor<hjkl_buffer::Buffer, H>,
5496) -> Option<((usize, usize), (usize, usize))> {
5497    word_text_object(ed, true, true, 1)
5498}
5499
5500/// Resolve the range of `aW` (around WORD) at the current cursor position.
5501/// Includes trailing whitespace (or leading whitespace if no trailing exists).
5502pub(crate) fn text_object_around_big_word_bridge<H: crate::types::Host>(
5503    ed: &Editor<hjkl_buffer::Buffer, H>,
5504) -> Option<((usize, usize), (usize, usize))> {
5505    word_text_object(ed, false, true, 1)
5506}
5507
5508// ─── Phase 4c pub text-object resolution bridges (quote + bracket) ──────────
5509//
5510// `pub(crate)` entry points called by the four new pub methods on `Editor`
5511// (`text_object_inner_quote`, `text_object_around_quote`,
5512// `text_object_inner_bracket`, `text_object_around_bracket`). They delegate to
5513// `quote_text_object` / `bracket_text_object` — the existing private resolvers
5514// — without touching any operator, register, or mode state.
5515//
5516// `bracket_text_object` returns `Option<(Pos, Pos, RangeKind)>`; the bridges
5517// strip the `RangeKind` tag so callers see a uniform
5518// `Option<((usize,usize),(usize,usize))>` shape, consistent with 4b.
5519
5520/// Resolve the range of `i<quote>` (inner quote) at the current cursor
5521/// position. `quote` is one of `'"'`, `'\''`, or `` '`' ``. Returns `None`
5522/// when the cursor's line contains fewer than two occurrences of `quote`.
5523pub(crate) fn text_object_inner_quote_bridge<H: crate::types::Host>(
5524    ed: &Editor<hjkl_buffer::Buffer, H>,
5525    quote: char,
5526) -> Option<((usize, usize), (usize, usize))> {
5527    quote_text_object(ed, quote, true)
5528}
5529
5530/// Resolve the range of `a<quote>` (around quote) at the current cursor
5531/// position. Includes surrounding whitespace on one side per vim semantics.
5532pub(crate) fn text_object_around_quote_bridge<H: crate::types::Host>(
5533    ed: &Editor<hjkl_buffer::Buffer, H>,
5534    quote: char,
5535) -> Option<((usize, usize), (usize, usize))> {
5536    quote_text_object(ed, quote, false)
5537}
5538
5539/// Resolve the range of `i<bracket>` (inner bracket pair). `open` must be
5540/// one of `'('`, `'{'`, `'['`, `'<'`; the corresponding close is derived
5541/// internally. Returns `None` when no enclosing pair is found. The returned
5542/// range excludes the bracket characters themselves. Multi-line bracket pairs
5543/// whose content spans more than one line are reported as a charwise range
5544/// covering the first content character through the last content character
5545/// (RangeKind metadata is stripped — callers receive start/end only).
5546pub(crate) fn text_object_inner_bracket_bridge<H: crate::types::Host>(
5547    ed: &Editor<hjkl_buffer::Buffer, H>,
5548    open: char,
5549) -> Option<((usize, usize), (usize, usize))> {
5550    bracket_text_object(ed, open, true, 1).map(|(s, e, _kind)| (s, e))
5551}
5552
5553/// Resolve the range of `a<bracket>` (around bracket pair). Includes the
5554/// bracket characters themselves. `open` must be one of `'('`, `'{'`, `'['`,
5555/// `'<'`.
5556pub(crate) fn text_object_around_bracket_bridge<H: crate::types::Host>(
5557    ed: &Editor<hjkl_buffer::Buffer, H>,
5558    open: char,
5559) -> Option<((usize, usize), (usize, usize))> {
5560    bracket_text_object(ed, open, false, 1).map(|(s, e, _kind)| (s, e))
5561}
5562
5563// ── Sentence bridges (is / as) ─────────────────────────────────────────────
5564
5565/// Resolve the range of `is` (inner sentence) at the cursor. Excludes
5566/// trailing whitespace.
5567pub(crate) fn text_object_inner_sentence_bridge<H: crate::types::Host>(
5568    ed: &Editor<hjkl_buffer::Buffer, H>,
5569) -> Option<((usize, usize), (usize, usize))> {
5570    sentence_text_object(ed, true, 1)
5571}
5572
5573/// Resolve the range of `as` (around sentence) at the cursor. Includes
5574/// trailing whitespace.
5575pub(crate) fn text_object_around_sentence_bridge<H: crate::types::Host>(
5576    ed: &Editor<hjkl_buffer::Buffer, H>,
5577) -> Option<((usize, usize), (usize, usize))> {
5578    sentence_text_object(ed, false, 1)
5579}
5580
5581// ── Paragraph bridges (ip / ap) ────────────────────────────────────────────
5582
5583/// Resolve the range of `ip` (inner paragraph) at the cursor. A paragraph
5584/// is a block of non-blank lines bounded by blank lines or buffer edges.
5585pub(crate) fn text_object_inner_paragraph_bridge<H: crate::types::Host>(
5586    ed: &Editor<hjkl_buffer::Buffer, H>,
5587) -> Option<((usize, usize), (usize, usize))> {
5588    paragraph_text_object(ed, true, 1)
5589}
5590
5591/// Resolve the range of `ap` (around paragraph) at the cursor. Includes one
5592/// trailing blank line when present.
5593pub(crate) fn text_object_around_paragraph_bridge<H: crate::types::Host>(
5594    ed: &Editor<hjkl_buffer::Buffer, H>,
5595) -> Option<((usize, usize), (usize, usize))> {
5596    paragraph_text_object(ed, false, 1)
5597}
5598
5599// ── Tag bridges (it / at) ──────────────────────────────────────────────────
5600
5601/// Resolve the range of `it` (inner tag) at the cursor. Matches XML/HTML-style
5602/// `<tag>...</tag>` pairs; returns the range of inner content between the open
5603/// and close tags.
5604pub(crate) fn text_object_inner_tag_bridge<H: crate::types::Host>(
5605    ed: &Editor<hjkl_buffer::Buffer, H>,
5606) -> Option<((usize, usize), (usize, usize))> {
5607    tag_text_object(ed, true)
5608}
5609
5610/// Resolve the range of `at` (around tag) at the cursor. Includes the open
5611/// and close tag delimiters themselves.
5612pub(crate) fn text_object_around_tag_bridge<H: crate::types::Host>(
5613    ed: &Editor<hjkl_buffer::Buffer, H>,
5614) -> Option<((usize, usize), (usize, usize))> {
5615    tag_text_object(ed, false)
5616}
5617
5618// ─── Rope utility helpers ──────────────────────────────────────────────────
5619
5620/// Return row `r` from a rope as an owned `String`, stripping the
5621/// trailing `\n` that ropey includes on non-final lines.
5622pub(crate) fn rope_line_to_str(rope: &ropey::Rope, r: usize) -> String {
5623    let s = rope.line(r).to_string();
5624    // ropey includes the newline; strip it so callers see bare content.
5625    if s.ends_with('\n') {
5626        s[..s.len() - 1].to_string()
5627    } else {
5628        s
5629    }
5630}
5631
5632/// Join rows `lo..=hi` from a rope into a single `String` separated by
5633/// `\n`. Callers must ensure `lo <= hi < rope.len_lines()`.
5634pub(crate) fn rope_row_range_str(rope: &ropey::Rope, lo: usize, hi: usize) -> String {
5635    let n = rope.len_lines();
5636    let lo = lo.min(n.saturating_sub(1));
5637    let hi = hi.min(n.saturating_sub(1));
5638    if lo > hi {
5639        return String::new();
5640    }
5641    // Use byte-slice to grab the full range in one rope walk.
5642    let start_byte = rope.line_to_byte(lo);
5643    // End byte: start of line hi+1, minus the newline separator, or
5644    // len_bytes() when hi is the last line.
5645    let end_byte = if hi + 1 < n {
5646        // line_to_byte(hi+1) points at the \n-terminated start of
5647        // the next line; step back one byte to drop that trailing \n.
5648        rope.line_to_byte(hi + 1).saturating_sub(1)
5649    } else {
5650        rope.len_bytes()
5651    };
5652    rope.byte_slice(start_byte..end_byte).to_string()
5653}
5654
5655/// Snapshot all rows from a rope as `Vec<String>` (no trailing `\n`).
5656/// Use only when the caller truly needs mutable per-row access; prefer
5657/// rope iterators otherwise.
5658pub(crate) fn rope_to_lines_vec(rope: &ropey::Rope) -> Vec<String> {
5659    let n = rope.len_lines();
5660    (0..n).map(|r| rope_line_to_str(rope, r)).collect()
5661}
5662
5663/// Pure greedy word-wrap of a slice of lines to `width` chars.
5664/// Returns `(original_slice, wrapped_lines)`.
5665/// Blank lines are preserved as paragraph separators.
5666fn greedy_wrap(original: &[String], width: usize) -> Vec<String> {
5667    let mut wrapped: Vec<String> = Vec::new();
5668    let mut paragraph: Vec<String> = Vec::new();
5669    let flush = |para: &mut Vec<String>, out: &mut Vec<String>, width: usize| {
5670        if para.is_empty() {
5671            return;
5672        }
5673        let words = para.join(" ");
5674        let mut current = String::new();
5675        for word in words.split_whitespace() {
5676            let extra = if current.is_empty() {
5677                word.chars().count()
5678            } else {
5679                current.chars().count() + 1 + word.chars().count()
5680            };
5681            if extra > width && !current.is_empty() {
5682                out.push(std::mem::take(&mut current));
5683                current.push_str(word);
5684            } else if current.is_empty() {
5685                current.push_str(word);
5686            } else {
5687                current.push(' ');
5688                current.push_str(word);
5689            }
5690        }
5691        if !current.is_empty() {
5692            out.push(current);
5693        }
5694        para.clear();
5695    };
5696    for line in original {
5697        if line.trim().is_empty() {
5698            flush(&mut paragraph, &mut wrapped, width);
5699            wrapped.push(String::new());
5700        } else {
5701            paragraph.push(line.clone());
5702        }
5703    }
5704    flush(&mut paragraph, &mut wrapped, width);
5705    wrapped
5706}
5707
5708/// Greedy word-wrap the rows in `[top, bot]` to `settings.textwidth`.
5709/// Splits on blank-line boundaries so paragraph structure is
5710/// preserved. Each paragraph's words are joined with single spaces
5711/// before re-wrapping. Cursor lands at `(top, 0)` after the call
5712/// (via `ed.restore`).
5713fn reflow_rows<H: crate::types::Host>(
5714    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5715    top: usize,
5716    bot: usize,
5717) {
5718    let width = ed.settings().textwidth.max(1);
5719    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
5720    let bot = bot.min(lines.len().saturating_sub(1));
5721    if top > bot {
5722        return;
5723    }
5724    let original = lines[top..=bot].to_vec();
5725    let wrapped = greedy_wrap(&original, width);
5726
5727    // vim leaves the cursor on the last NON-BLANK line of the reflowed range
5728    // (a trailing blank from `ap` etc. is not counted).
5729    let last_offset = wrapped
5730        .iter()
5731        .rposition(|l| !l.trim().is_empty())
5732        .unwrap_or(0);
5733    let last_row = top + last_offset;
5734
5735    // Splice back. push_undo above means `u` reverses.
5736    let after: Vec<String> = lines.split_off(bot + 1);
5737    lines.truncate(top);
5738    lines.extend(wrapped);
5739    lines.extend(after);
5740    ed.restore(lines, (last_row, 0));
5741    move_first_non_whitespace(ed);
5742    ed.mark_content_dirty();
5743}
5744
5745/// Same reflow as `reflow_rows` but also returns the pre-reflow slice
5746/// and the wrapped lines so the caller can compute a character-preserving
5747/// cursor position via [`reflow_keep_cursor`].
5748fn reflow_rows_keep_cursor<H: crate::types::Host>(
5749    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5750    top: usize,
5751    bot: usize,
5752) -> (Vec<String>, Vec<String>) {
5753    let width = ed.settings().textwidth.max(1);
5754    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
5755    let bot = bot.min(lines.len().saturating_sub(1));
5756    if top > bot {
5757        return (Vec::new(), Vec::new());
5758    }
5759    let original = lines[top..=bot].to_vec();
5760    let wrapped = greedy_wrap(&original, width);
5761
5762    let after: Vec<String> = lines.split_off(bot + 1);
5763    lines.truncate(top);
5764    lines.extend(wrapped.clone());
5765    lines.extend(after);
5766    ed.restore(lines, (top, 0));
5767    ed.mark_content_dirty();
5768    (original, wrapped)
5769}
5770
5771/// Compute the new `(row, col)` that preserves the character the cursor
5772/// was on after `reflow_rows` has been applied to `[top, bot]`.
5773///
5774/// Algorithm (mirrors nvim's `gw` behaviour):
5775/// 1. Count the char-index of `(cursor_row, cursor_col)` relative to the
5776///    start of line `top` in `before_lines` (the pre-reflow snapshot).
5777/// 2. Walk the `after_lines` (the wrapped output) to find the row/col
5778///    that has the same char index.
5779///
5780/// If the cursor was past the end of the reflowed content (e.g. beyond
5781/// the last char), we clamp to the last char of the last reflowed line.
5782fn reflow_keep_cursor(
5783    top: usize,
5784    cursor_row: usize,
5785    cursor_col: usize,
5786    before_lines: &[String],
5787    after_lines: &[String],
5788) -> (usize, usize) {
5789    // Char offset of cursor within the before_lines range.
5790    // Each line contributes its chars; lines are separated by a single
5791    // space in the collapsed paragraph — but since reflow joins everything
5792    // and re-wraps with spaces, counting by chars-per-line (plus the
5793    // conceptual space separator between lines) mirrors the join.
5794    //
5795    // The simpler approach (which nvim appears to use): the cursor offset
5796    // within the range is the sum of chars in lines before cursor_row
5797    // (each + 1 for the space/newline separator) plus cursor_col, then
5798    // find that position in the wrapped text.
5799    //
5800    // Actually, since reflow collapses whitespace (split_whitespace),
5801    // the simplest approach is to track the cursor's char in the ORIGINAL
5802    // concatenated text and find it in the reflowed text.
5803
5804    // Build the original range text as it appears when joined for wrapping:
5805    // same as what reflow does internally — join with spaces.
5806    // But we want raw character index, so we accumulate char counts per line
5807    // (without the trailing newline).
5808    let relative_row = cursor_row.saturating_sub(top);
5809    let mut char_offset: usize = 0;
5810    for (i, line) in before_lines.iter().enumerate() {
5811        if i == relative_row {
5812            // Add clamped col within this line.
5813            let line_len = line.chars().count();
5814            char_offset += cursor_col.min(line_len);
5815            break;
5816        }
5817        // Each line contributes its chars plus a newline (or space boundary).
5818        char_offset += line.chars().count() + 1;
5819    }
5820
5821    // Now find char_offset in after_lines.
5822    let mut remaining = char_offset;
5823    for (i, line) in after_lines.iter().enumerate() {
5824        let len = line.chars().count();
5825        if remaining <= len {
5826            // The col is clamped to line_len - 1 in Normal mode.
5827            let col = remaining.min(if len == 0 { 0 } else { len.saturating_sub(1) });
5828            return (top + i, col);
5829        }
5830        // Not on this line; subtract line len + 1 (newline separator).
5831        remaining = remaining.saturating_sub(len + 1);
5832    }
5833
5834    // Cursor was beyond the end of the reflowed content — clamp to last line.
5835    let last = after_lines.len().saturating_sub(1);
5836    let last_len = after_lines
5837        .get(last)
5838        .map(|l| l.chars().count())
5839        .unwrap_or(0);
5840    let col = if last_len == 0 { 0 } else { last_len - 1 };
5841    (top + last, col)
5842}
5843
5844/// Transform the range `[top, bot]` (vim `RangeKind`) in place with
5845/// the given case operator. Cursor lands on `top` afterward — vim
5846/// convention for `gU{motion}` / `gu{motion}` / `g~{motion}`.
5847/// Preserves the textarea yank buffer (vim's case operators don't
5848/// touch registers).
5849fn apply_case_op_to_selection<H: crate::types::Host>(
5850    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5851    op: Operator,
5852    top: (usize, usize),
5853    bot: (usize, usize),
5854    kind: RangeKind,
5855) {
5856    use hjkl_buffer::Edit;
5857    ed.push_undo();
5858    let saved_yank = ed.yank().to_string();
5859    let saved_yank_linewise = ed.vim.yank_linewise;
5860    let selection = cut_vim_range(ed, top, bot, kind);
5861    let transformed = match op {
5862        Operator::Uppercase => selection.to_uppercase(),
5863        Operator::Lowercase => selection.to_lowercase(),
5864        Operator::ToggleCase => toggle_case_str(&selection),
5865        Operator::Rot13 => rot13_str(&selection),
5866        _ => unreachable!(),
5867    };
5868    if !transformed.is_empty() {
5869        let cursor = buf_cursor_pos(&ed.buffer);
5870        ed.mutate_edit(Edit::InsertStr {
5871            at: cursor,
5872            text: transformed,
5873        });
5874    }
5875    buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
5876    ed.push_buffer_cursor_to_textarea();
5877    ed.set_yank(saved_yank);
5878    ed.vim.yank_linewise = saved_yank_linewise;
5879    ed.vim.mode = Mode::Normal;
5880}
5881
5882/// Prepend `count * shiftwidth` spaces to each row in `[top, bot]`.
5883/// Rows that are empty are skipped (vim leaves blank lines alone when
5884/// indenting). `shiftwidth` is read from `editor.settings()` so
5885/// `:set shiftwidth=N` takes effect on the next operation.
5886fn indent_rows<H: crate::types::Host>(
5887    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5888    top: usize,
5889    bot: usize,
5890    count: usize,
5891) {
5892    ed.sync_buffer_content_from_textarea();
5893    let width = ed.settings().shiftwidth.saturating_mul(count.max(1));
5894    // Honour `expandtab` (#263): `>>` under `noexpandtab` must insert hard tabs,
5895    // not spaces. Render `width` columns as tabs (`width / tabstop`) plus any
5896    // sub-tab remainder as spaces; under `expandtab` it stays all spaces.
5897    let pad: String = if ed.settings().expandtab {
5898        " ".repeat(width)
5899    } else {
5900        let tabstop = ed.settings().tabstop.max(1);
5901        let tabs = width / tabstop;
5902        let spaces = width % tabstop;
5903        format!("{}{}", "\t".repeat(tabs), " ".repeat(spaces))
5904    };
5905    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
5906    let bot = bot.min(lines.len().saturating_sub(1));
5907    for line in lines.iter_mut().take(bot + 1).skip(top) {
5908        if !line.is_empty() {
5909            line.insert_str(0, &pad);
5910        }
5911    }
5912    // Restore cursor to first non-blank of the top row so the next
5913    // vertical motion aims sensibly — matches vim's `>>` convention.
5914    ed.restore(lines, (top, 0));
5915    move_first_non_whitespace(ed);
5916}
5917
5918/// Remove up to `count * shiftwidth` leading spaces (or tabs) from
5919/// each row in `[top, bot]`. Rows with less leading whitespace have
5920/// all their indent stripped, not clipped to zero length.
5921fn outdent_rows<H: crate::types::Host>(
5922    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5923    top: usize,
5924    bot: usize,
5925    count: usize,
5926) {
5927    ed.sync_buffer_content_from_textarea();
5928    let width = ed.settings().shiftwidth.saturating_mul(count.max(1));
5929    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
5930    let bot = bot.min(lines.len().saturating_sub(1));
5931    for line in lines.iter_mut().take(bot + 1).skip(top) {
5932        let strip: usize = line
5933            .chars()
5934            .take(width)
5935            .take_while(|c| *c == ' ' || *c == '\t')
5936            .count();
5937        if strip > 0 {
5938            let byte_len: usize = line.chars().take(strip).map(|c| c.len_utf8()).sum();
5939            line.drain(..byte_len);
5940        }
5941    }
5942    ed.restore(lines, (top, 0));
5943    move_first_non_whitespace(ed);
5944}
5945
5946/// Count the number of open/close bracket pairs on a single line for the
5947/// auto-indent depth scanner. Only bare bracket scanning — does NOT handle
5948/// string literals or comments (v1 limitation, documented on
5949/// `auto_indent_range_bridge`).
5950/// Net bracket count `(open - close)` for a single line, skipping
5951/// brackets inside `//` line comments, `"..."` string literals, and
5952/// `'X'` char literals.
5953///
5954/// String / char escapes (`\"`, `\'`, `\\`) are honored so the closing
5955/// quote isn't missed when the literal contains a backslash.
5956///
5957/// Limitations:
5958/// - Block comments `/* ... */` are NOT tracked across lines (a single
5959///   line `/* foo { bar } */` is correctly skipped only because the
5960///   `/*` and `*/` are on the same line and we'd see `{` after `/*`).
5961///   For v1 we leave this since block comments mid-code are rare.
5962/// - Raw string literals `r"..."` / `r#"..."#` are NOT special-cased.
5963/// - Lifetime annotations like `'a` look like an unterminated char
5964///   literal — handled by the heuristic that a char literal MUST close
5965///   within the line; if the closing `'` isn't found, treat the `'` as
5966///   a normal character (lifetime).
5967///
5968/// Pre-fix the scan was naive — `//! ... }` on a doc comment
5969/// decremented depth, cascading wrong indentation through the rest of
5970/// the file. This caused ~19% of lines to mis-indent on a real Rust
5971/// source diagnostic.
5972fn bracket_net(line: &str) -> i32 {
5973    let mut net: i32 = 0;
5974    let mut chars = line.chars().peekable();
5975    while let Some(ch) = chars.next() {
5976        match ch {
5977            // `//` → rest of line is a comment, stop.
5978            '/' if chars.peek() == Some(&'/') => return net,
5979            '"' => {
5980                // String literal — consume until unescaped closing `"`.
5981                while let Some(c) = chars.next() {
5982                    match c {
5983                        '\\' => {
5984                            chars.next();
5985                        } // skip escape byte
5986                        '"' => break,
5987                        _ => {}
5988                    }
5989                }
5990            }
5991            '\'' => {
5992                // Char literal OR lifetime. A char literal closes within
5993                // a few chars (one or two for escapes). A lifetime is
5994                // `'ident` with no closing quote.
5995                //
5996                // Strategy: peek ahead for a closing `'`. If found
5997                // within ~4 chars, consume as char literal. Otherwise
5998                // treat the `'` as the start of a lifetime — leave the
5999                // remaining chars to be scanned normally.
6000                let saved: Vec<char> = chars.clone().take(5).collect();
6001                let close_idx = if saved.first() == Some(&'\\') {
6002                    saved.iter().skip(2).position(|&c| c == '\'').map(|p| p + 2)
6003                } else {
6004                    saved.iter().skip(1).position(|&c| c == '\'').map(|p| p + 1)
6005                };
6006                if let Some(idx) = close_idx {
6007                    for _ in 0..=idx {
6008                        chars.next();
6009                    }
6010                }
6011                // If no close found, leave chars alone — lifetime path.
6012            }
6013            '{' | '(' | '[' => net += 1,
6014            '}' | ')' | ']' => net -= 1,
6015            _ => {}
6016        }
6017    }
6018    net
6019}
6020
6021/// Reindent rows `[top, bot]` using shiftwidth-based bracket-depth counting.
6022///
6023/// The indent for each line is computed as follows:
6024/// 1. Scan all rows from 0 up to the target row, accumulating a bracket depth
6025///    (`depth`) from net open − close brackets per line. The scan starts at row
6026///    0 to give correct depth for code that appears mid-buffer.
6027/// 2. For the target line, peek at its first non-whitespace character:
6028///    if it is a close bracket (`}`, `)`, `]`) then `effective_depth =
6029///    depth.saturating_sub(1)`; otherwise `effective_depth = depth`.
6030/// 3. Strip the line's existing leading whitespace and prepend
6031///    `effective_depth × indent_unit` where `indent_unit` is `"\t"` when
6032///    `expandtab == false` or `" " × shiftwidth` when `expandtab == true`.
6033/// 4. Empty / whitespace-only lines are left empty (no trailing whitespace).
6034/// 5. After computing the new line, advance `depth` by the line's bracket
6035///    net count (open − close), where the leading close-bracket already
6036///    contributed `−1` to the net of its own line.
6037///
6038/// **v1 limitation**: the bracket scan is naive — it does not skip brackets
6039/// inside string literals (`"{"`, `'['`) or comments (`// {`). Code with
6040/// such patterns will produce incorrect indent depths. Tree-sitter / LSP
6041/// indentation is deferred to a follow-up.
6042fn auto_indent_rows<H: crate::types::Host>(
6043    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6044    top: usize,
6045    bot: usize,
6046) {
6047    ed.sync_buffer_content_from_textarea();
6048    let shiftwidth = ed.settings().shiftwidth;
6049    let expandtab = ed.settings().expandtab;
6050    let indent_unit: String = if expandtab {
6051        " ".repeat(shiftwidth)
6052    } else {
6053        "\t".to_string()
6054    };
6055
6056    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
6057    let bot = bot.min(lines.len().saturating_sub(1));
6058
6059    // Accumulate bracket depth from row 0 up to `top - 1` so we start with
6060    // the correct depth for the first line of the target range.
6061    let mut depth: i32 = 0;
6062    for line in lines.iter().take(top) {
6063        depth += bracket_net(line);
6064        if depth < 0 {
6065            depth = 0;
6066        }
6067    }
6068
6069    for line in lines.iter_mut().take(bot + 1).skip(top) {
6070        let trimmed_owned = line.trim_start().to_owned();
6071        // Empty / whitespace-only lines stay empty.
6072        if trimmed_owned.is_empty() {
6073            *line = String::new();
6074            // depth contribution from an empty line is zero; no bracket scan needed.
6075            continue;
6076        }
6077
6078        // Detect leading close-bracket for effective depth.
6079        let starts_with_close = trimmed_owned
6080            .chars()
6081            .next()
6082            .is_some_and(|c| matches!(c, '}' | ')' | ']'));
6083        // Chain continuation: a line starting with `.` (e.g. `.foo()`)
6084        // hangs off the previous expression and gets one extra indent
6085        // level, matching cargo fmt / clang-format conventions for
6086        // method chains like:
6087        //   let x = foo()
6088        //       .bar()
6089        //       .baz();
6090        // Range expressions (`..`) and try-chains (`?.`) are out of
6091        // scope for v1 — single leading `.` is the common case.
6092        let starts_with_dot = trimmed_owned.starts_with('.')
6093            && !trimmed_owned.starts_with("..")
6094            && !trimmed_owned.starts_with(".;");
6095        let effective_depth = if starts_with_close {
6096            depth.saturating_sub(1)
6097        } else if starts_with_dot {
6098            depth.saturating_add(1)
6099        } else {
6100            depth
6101        } as usize;
6102
6103        // Build new line: indent × depth + stripped content.
6104        let new_line = format!("{}{}", indent_unit.repeat(effective_depth), trimmed_owned);
6105
6106        // Advance depth by this line's net bracket count (scan trimmed content).
6107        depth += bracket_net(&trimmed_owned);
6108        if depth < 0 {
6109            depth = 0;
6110        }
6111
6112        *line = new_line;
6113    }
6114
6115    // Restore cursor to the first non-blank of `top` (vim parity for `==`).
6116    ed.restore(lines, (top, 0));
6117    move_first_non_whitespace(ed);
6118    // Record the touched row range so the host can display a visual flash.
6119    ed.last_indent_range = Some((top, bot));
6120}
6121
6122fn toggle_case_str(s: &str) -> String {
6123    s.chars()
6124        .map(|c| {
6125            if c.is_lowercase() {
6126                c.to_uppercase().next().unwrap_or(c)
6127            } else if c.is_uppercase() {
6128                c.to_lowercase().next().unwrap_or(c)
6129            } else {
6130                c
6131            }
6132        })
6133        .collect()
6134}
6135
6136fn order(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) {
6137    if a <= b { (a, b) } else { (b, a) }
6138}
6139
6140/// Clamp the buffer cursor to normal-mode valid position: col may not
6141/// exceed `line.chars().count().saturating_sub(1)` (or 0 on an empty
6142/// line). Vim applies this clamp on every return to Normal mode after an
6143/// operator or Esc-from-insert.
6144fn clamp_cursor_to_normal_mode<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
6145    let (row, col) = ed.cursor();
6146    let line_chars = buf_line_chars(&ed.buffer, row);
6147    let max_col = line_chars.saturating_sub(1);
6148    if col > max_col {
6149        buf_set_cursor_rc(&mut ed.buffer, row, max_col);
6150        ed.push_buffer_cursor_to_textarea();
6151    }
6152}
6153
6154// ─── dd/cc/yy ──────────────────────────────────────────────────────────────
6155
6156/// Expand a linewise `[start, end]` row range so it fully covers every CLOSED
6157/// fold it overlaps — vim's rule that a linewise operator on a closed fold acts
6158/// on the whole fold. Loops until stable so nested closed folds are absorbed.
6159fn expand_linewise_over_closed_folds(
6160    buf: &hjkl_buffer::Buffer,
6161    mut start: usize,
6162    mut end: usize,
6163) -> (usize, usize) {
6164    let folds = buf.folds();
6165    if folds.is_empty() {
6166        return (start, end);
6167    }
6168    loop {
6169        let mut changed = false;
6170        for f in &folds {
6171            if !f.closed {
6172                continue;
6173            }
6174            // Does this closed fold overlap the current range?
6175            if f.start_row <= end && f.end_row >= start {
6176                if f.start_row < start {
6177                    start = f.start_row;
6178                    changed = true;
6179                }
6180                if f.end_row > end {
6181                    end = f.end_row;
6182                    changed = true;
6183                }
6184            }
6185        }
6186        if !changed {
6187            break;
6188        }
6189    }
6190    (start, end)
6191}
6192
6193fn execute_line_op<H: crate::types::Host>(
6194    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6195    op: Operator,
6196    count: usize,
6197) {
6198    // Folded counts (`2d3d` → 6) can exceed a single prefix's cap; re-clamp
6199    // at vim's count ceiling (`:h count`).
6200    let count = count.min(MAX_COUNT);
6201    let (row, col) = ed.cursor();
6202    let total = buf_row_count(&ed.buffer);
6203    // Vim: `[count]op` for a linewise operator implies a `count_` motion that
6204    // moves `count - 1` lines down. On the last line that motion can't move at
6205    // all, so the whole operator aborts (E16) — `2dd`/`2yy`/`5>>`/`5<<` on the
6206    // final line are no-ops, not "operate on the one remaining line". When the
6207    // cursor is above the last line the motion clamps to the buffer end instead.
6208    //
6209    // A trailing newline is stored as a phantom empty final row, so the last
6210    // *content* line is one above it; use that as the boundary.
6211    let last_content_row = if total >= 2
6212        && buf_line(&ed.buffer, total - 1)
6213            .map(|s| s.is_empty())
6214            .unwrap_or(false)
6215    {
6216        total - 2
6217    } else {
6218        total.saturating_sub(1)
6219    };
6220    if count >= 2 && row >= last_content_row {
6221        return;
6222    }
6223    let end_row = (row + count.saturating_sub(1)).min(total.saturating_sub(1));
6224
6225    // Vim: a linewise operator (`dd`/`yy`/`cc`/`>>`/…) with the cursor on a
6226    // CLOSED fold operates on the ENTIRE fold, not just the cursor line. Expand
6227    // the `[row, end_row]` range to cover any closed fold it touches (repeats
6228    // until stable so nested folds are absorbed too).
6229    let (row, end_row) = expand_linewise_over_closed_folds(&ed.buffer, row, end_row);
6230
6231    match op {
6232        Operator::Yank => {
6233            // yy must not move the cursor.
6234            let text = read_vim_range(ed, (row, col), (end_row, 0), RangeKind::Linewise);
6235            if !text.is_empty() {
6236                ed.record_yank_to_host(text.clone());
6237                ed.record_yank(text, true);
6238            }
6239            // Vim `:h '[` / `:h ']`: yy/Nyy — linewise yank; `[` =
6240            // (top_row, 0), `]` = (bot_row, last_col).
6241            let last_col = buf_line_chars(&ed.buffer, end_row).saturating_sub(1);
6242            ed.set_mark('[', (row, 0));
6243            ed.set_mark(']', (end_row, last_col));
6244            buf_set_cursor_rc(&mut ed.buffer, row, col);
6245            ed.push_buffer_cursor_to_textarea();
6246            ed.vim.mode = Mode::Normal;
6247        }
6248        Operator::Delete => {
6249            ed.push_undo();
6250            let deleted_through_last = end_row + 1 >= total;
6251            cut_vim_range(ed, (row, col), (end_row, 0), RangeKind::Linewise);
6252            // Vim's `dd` / `Ndd` leaves the cursor on the *first
6253            // non-blank* of the line that now occupies `row` — or, if
6254            // the deletion consumed the last line, the line above it.
6255            let total_after = buf_row_count(&ed.buffer);
6256            let raw_target = if deleted_through_last {
6257                row.saturating_sub(1).min(total_after.saturating_sub(1))
6258            } else {
6259                row.min(total_after.saturating_sub(1))
6260            };
6261            // Clamp off the trailing phantom empty row that arises from a
6262            // buffer with a trailing newline (stored as ["...", ""]). If
6263            // the target row is the trailing empty row and there is a real
6264            // content row above it, use that instead — matching vim's view
6265            // that the trailing `\n` is a terminator, not a separator.
6266            let target_row = if raw_target > 0
6267                && raw_target + 1 == total_after
6268                && buf_line(&ed.buffer, raw_target)
6269                    .map(|s| s.is_empty())
6270                    .unwrap_or(false)
6271            {
6272                raw_target - 1
6273            } else {
6274                raw_target
6275            };
6276            buf_set_cursor_rc(&mut ed.buffer, target_row, 0);
6277            ed.push_buffer_cursor_to_textarea();
6278            move_first_non_whitespace(ed);
6279            ed.sticky_col = Some(ed.cursor().1);
6280            ed.vim.mode = Mode::Normal;
6281            // Vim `:h '[` / `:h ']`: dd/Ndd — both marks park at the
6282            // post-delete cursor position (the join point).
6283            let pos = ed.cursor();
6284            ed.set_mark('[', pos);
6285            ed.set_mark(']', pos);
6286        }
6287        Operator::Change => {
6288            // `cc` / `3cc`: delegate to the shared linewise-change helper
6289            // which preserves the first line's indent, leaves one row open,
6290            // and enters insert mode.
6291            change_linewise_rows(ed, row, end_row);
6292        }
6293        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {
6294            // `gUU` / `guu` / `g~~` / `g??` — linewise case/rot13 transform over
6295            // [row, end_row]. Preserve cursor on `row` (first non-blank
6296            // lines up with vim's behaviour).
6297            apply_case_op_to_selection(ed, op, (row, col), (end_row, 0), RangeKind::Linewise);
6298            // After case-op on a linewise range vim puts the cursor on
6299            // the first non-blank of the starting line.
6300            move_first_non_whitespace(ed);
6301        }
6302        Operator::Indent | Operator::Outdent => {
6303            // `>>` / `N>>` / `<<` / `N<<` — linewise indent / outdent.
6304            ed.push_undo();
6305            if op == Operator::Indent {
6306                indent_rows(ed, row, end_row, 1);
6307            } else {
6308                outdent_rows(ed, row, end_row, 1);
6309            }
6310            ed.sticky_col = Some(ed.cursor().1);
6311            ed.vim.mode = Mode::Normal;
6312        }
6313        // No doubled form — `zfzf` is two consecutive `zf` chords.
6314        Operator::Fold => unreachable!("Fold has no line-op double"),
6315        Operator::Reflow => {
6316            // `gqq` / `Ngqq` — reflow `count` rows starting at the cursor.
6317            ed.push_undo();
6318            reflow_rows(ed, row, end_row);
6319            move_first_non_whitespace(ed);
6320            ed.sticky_col = Some(ed.cursor().1);
6321            ed.vim.mode = Mode::Normal;
6322        }
6323        Operator::ReflowKeepCursor => {
6324            // `gww` / `Ngww` — reflow `count` rows starting at the cursor,
6325            // but leave the cursor at the character it was on before reflow.
6326            let saved = ed.cursor();
6327            ed.push_undo();
6328            let (before, after) = reflow_rows_keep_cursor(ed, row, end_row);
6329            let (new_row, new_col) = reflow_keep_cursor(row, saved.0, saved.1, &before, &after);
6330            buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
6331            ed.push_buffer_cursor_to_textarea();
6332            ed.sticky_col = Some(new_col);
6333            ed.vim.mode = Mode::Normal;
6334        }
6335        Operator::AutoIndent => {
6336            // `==` / `N==` — auto-indent `count` rows starting at cursor.
6337            ed.push_undo();
6338            auto_indent_rows(ed, row, end_row);
6339            ed.sticky_col = Some(ed.cursor().1);
6340            ed.vim.mode = Mode::Normal;
6341        }
6342        Operator::Filter => {
6343            // Filter is dispatched through Editor::filter_range, not here.
6344        }
6345        Operator::Comment => {
6346            // Comment is dispatched through Editor::toggle_comment_range, not here.
6347            // The doubled `gcc` path calls toggle_comment_range directly in
6348            // apply_after_g, then records last_change. execute_line_op should
6349            // not be reached for Comment — no-op if it is.
6350        }
6351    }
6352}
6353
6354// ─── Visual mode operators ─────────────────────────────────────────────────
6355
6356pub(crate) fn apply_visual_operator<H: crate::types::Host>(
6357    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6358    op: Operator,
6359    count: usize,
6360) {
6361    // `count` is the number of indent levels for `>` / `<` (vim `2>` = two
6362    // shiftwidths); other visual operators ignore it.
6363    let levels = count.max(1);
6364    match ed.vim.mode {
6365        Mode::VisualLine => {
6366            let cursor_row = buf_cursor_pos(&ed.buffer).row;
6367            let top = cursor_row.min(ed.vim.visual_line_anchor);
6368            let bot = cursor_row.max(ed.vim.visual_line_anchor);
6369            ed.vim.yank_linewise = true;
6370            match op {
6371                Operator::Yank => {
6372                    let text = read_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
6373                    if !text.is_empty() {
6374                        ed.record_yank_to_host(text.clone());
6375                        ed.record_yank(text, true);
6376                    }
6377                    buf_set_cursor_rc(&mut ed.buffer, top, 0);
6378                    ed.push_buffer_cursor_to_textarea();
6379                    ed.vim.mode = Mode::Normal;
6380                }
6381                Operator::Delete => {
6382                    ed.push_undo();
6383                    cut_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
6384                    ed.vim.mode = Mode::Normal;
6385                }
6386                Operator::Change => {
6387                    // Vim `Vc` / `Vjc`: same linewise-change semantics as
6388                    // `cc` — preserve first line's indent, enter insert.
6389                    change_linewise_rows(ed, top, bot);
6390                }
6391                Operator::Uppercase
6392                | Operator::Lowercase
6393                | Operator::ToggleCase
6394                | Operator::Rot13 => {
6395                    let bot = buf_cursor_pos(&ed.buffer)
6396                        .row
6397                        .max(ed.vim.visual_line_anchor);
6398                    apply_case_op_to_selection(ed, op, (top, 0), (bot, 0), RangeKind::Linewise);
6399                    move_first_non_whitespace(ed);
6400                }
6401                Operator::Indent | Operator::Outdent => {
6402                    ed.push_undo();
6403                    let (cursor_row, _) = ed.cursor();
6404                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
6405                    if op == Operator::Indent {
6406                        indent_rows(ed, top, bot, levels);
6407                    } else {
6408                        outdent_rows(ed, top, bot, levels);
6409                    }
6410                    ed.vim.mode = Mode::Normal;
6411                }
6412                Operator::Reflow => {
6413                    ed.push_undo();
6414                    let (cursor_row, _) = ed.cursor();
6415                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
6416                    reflow_rows(ed, top, bot);
6417                    ed.vim.mode = Mode::Normal;
6418                }
6419                Operator::ReflowKeepCursor => {
6420                    let saved = ed.cursor();
6421                    ed.push_undo();
6422                    let (cursor_row, _) = ed.cursor();
6423                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
6424                    let (before, after) = reflow_rows_keep_cursor(ed, top, bot);
6425                    let (new_row, new_col) =
6426                        reflow_keep_cursor(top, saved.0, saved.1, &before, &after);
6427                    buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
6428                    ed.push_buffer_cursor_to_textarea();
6429                    ed.vim.mode = Mode::Normal;
6430                }
6431                Operator::AutoIndent => {
6432                    ed.push_undo();
6433                    let (cursor_row, _) = ed.cursor();
6434                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
6435                    auto_indent_rows(ed, top, bot);
6436                    ed.vim.mode = Mode::Normal;
6437                }
6438                // Filter is dispatched through Editor::filter_range, not here.
6439                Operator::Filter => {}
6440                // Comment is dispatched through the app layer (engine_actions.rs), not here.
6441                Operator::Comment => {}
6442                // Visual `zf` is handled inline in `handle_after_z`,
6443                // never routed through this dispatcher.
6444                Operator::Fold => unreachable!("Visual zf takes its own path"),
6445            }
6446        }
6447        Mode::Visual => {
6448            ed.vim.yank_linewise = false;
6449            let anchor = ed.vim.visual_anchor;
6450            let cursor = ed.cursor();
6451            let (top, bot) = order(anchor, cursor);
6452            match op {
6453                Operator::Yank => {
6454                    let text = read_vim_range(ed, top, bot, RangeKind::Inclusive);
6455                    if !text.is_empty() {
6456                        ed.record_yank_to_host(text.clone());
6457                        ed.record_yank(text, false);
6458                    }
6459                    buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
6460                    ed.push_buffer_cursor_to_textarea();
6461                    ed.vim.mode = Mode::Normal;
6462                }
6463                Operator::Delete => {
6464                    ed.push_undo();
6465                    cut_vim_range(ed, top, bot, RangeKind::Inclusive);
6466                    ed.vim.mode = Mode::Normal;
6467                }
6468                Operator::Change => {
6469                    ed.push_undo();
6470                    cut_vim_range(ed, top, bot, RangeKind::Inclusive);
6471                    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
6472                }
6473                Operator::Uppercase
6474                | Operator::Lowercase
6475                | Operator::ToggleCase
6476                | Operator::Rot13 => {
6477                    // Anchor stays where the visual selection started.
6478                    let anchor = ed.vim.visual_anchor;
6479                    let cursor = ed.cursor();
6480                    let (top, bot) = order(anchor, cursor);
6481                    apply_case_op_to_selection(ed, op, top, bot, RangeKind::Inclusive);
6482                }
6483                Operator::Indent | Operator::Outdent => {
6484                    ed.push_undo();
6485                    let anchor = ed.vim.visual_anchor;
6486                    let cursor = ed.cursor();
6487                    let (top, bot) = order(anchor, cursor);
6488                    if op == Operator::Indent {
6489                        indent_rows(ed, top.0, bot.0, levels);
6490                    } else {
6491                        outdent_rows(ed, top.0, bot.0, levels);
6492                    }
6493                    ed.vim.mode = Mode::Normal;
6494                }
6495                Operator::Reflow => {
6496                    ed.push_undo();
6497                    let anchor = ed.vim.visual_anchor;
6498                    let cursor = ed.cursor();
6499                    let (top, bot) = order(anchor, cursor);
6500                    reflow_rows(ed, top.0, bot.0);
6501                    ed.vim.mode = Mode::Normal;
6502                }
6503                Operator::ReflowKeepCursor => {
6504                    let saved = ed.cursor();
6505                    ed.push_undo();
6506                    let anchor = ed.vim.visual_anchor;
6507                    let cursor = ed.cursor();
6508                    let (top, bot) = order(anchor, cursor);
6509                    let (before, after) = reflow_rows_keep_cursor(ed, top.0, bot.0);
6510                    let (new_row, new_col) =
6511                        reflow_keep_cursor(top.0, saved.0, saved.1, &before, &after);
6512                    buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
6513                    ed.push_buffer_cursor_to_textarea();
6514                    ed.vim.mode = Mode::Normal;
6515                }
6516                Operator::AutoIndent => {
6517                    ed.push_undo();
6518                    let anchor = ed.vim.visual_anchor;
6519                    let cursor = ed.cursor();
6520                    let (top, bot) = order(anchor, cursor);
6521                    auto_indent_rows(ed, top.0, bot.0);
6522                    ed.vim.mode = Mode::Normal;
6523                }
6524                // Filter is dispatched through Editor::filter_range, not here.
6525                Operator::Filter => {}
6526                // Comment is dispatched through the app layer (engine_actions.rs), not here.
6527                Operator::Comment => {}
6528                Operator::Fold => unreachable!("Visual zf takes its own path"),
6529            }
6530        }
6531        Mode::VisualBlock => apply_block_operator(ed, op, levels),
6532        _ => {}
6533    }
6534}
6535
6536/// Compute `(top_row, bot_row, left_col, right_col)` for the current
6537/// VisualBlock selection. Columns are inclusive on both ends. Uses the
6538/// tracked virtual column (updated by h/l, preserved across j/k) so
6539/// ragged / empty rows don't collapse the block's width.
6540fn block_bounds<H: crate::types::Host>(
6541    ed: &Editor<hjkl_buffer::Buffer, H>,
6542) -> (usize, usize, usize, usize) {
6543    let (ar, ac) = ed.vim.block_anchor;
6544    let (cr, _) = ed.cursor();
6545    let cc = ed.vim.block_vcol;
6546    let top = ar.min(cr);
6547    let bot = ar.max(cr);
6548    let left = ac.min(cc);
6549    let right = ac.max(cc);
6550    (top, bot, left, right)
6551}
6552
6553/// Update the virtual column after a motion in VisualBlock mode.
6554/// Horizontal motions sync `block_vcol` to the new cursor column;
6555/// vertical / non-h/l motions leave it alone so the intended column
6556/// survives clamping to shorter lines.
6557pub(crate) fn update_block_vcol<H: crate::types::Host>(
6558    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6559    motion: &Motion,
6560) {
6561    match motion {
6562        Motion::Left
6563        | Motion::Right
6564        | Motion::SpaceFwd
6565        | Motion::BackspaceBack
6566        | Motion::WordFwd
6567        | Motion::BigWordFwd
6568        | Motion::WordBack
6569        | Motion::BigWordBack
6570        | Motion::WordEnd
6571        | Motion::BigWordEnd
6572        | Motion::WordEndBack
6573        | Motion::BigWordEndBack
6574        | Motion::LineStart
6575        | Motion::FirstNonBlank
6576        | Motion::LineEnd
6577        | Motion::Find { .. }
6578        | Motion::FindRepeat { .. }
6579        | Motion::MatchBracket => {
6580            ed.vim.block_vcol = ed.cursor().1;
6581        }
6582        // Up / Down / FileTop / FileBottom / Search — preserve vcol.
6583        _ => {}
6584    }
6585}
6586
6587/// Yank / delete / change / replace a rectangular selection. Yanked text
6588/// is stored as one string per row joined with `\n` so pasting reproduces
6589/// the block as sequential lines. (Vim's true block-paste reinserts as
6590/// columns; we render the content with our char-wise paste path.)
6591fn apply_block_operator<H: crate::types::Host>(
6592    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6593    op: Operator,
6594    count: usize,
6595) {
6596    let (top, bot, left, right) = block_bounds(ed);
6597    // Snapshot the block text for yank / clipboard.
6598    let yank = block_yank(ed, top, bot, left, right);
6599
6600    match op {
6601        Operator::Yank => {
6602            if !yank.is_empty() {
6603                ed.record_yank_to_host(yank.clone());
6604                ed.record_yank(yank, false);
6605            }
6606            ed.vim.mode = Mode::Normal;
6607            ed.jump_cursor(top, left);
6608        }
6609        Operator::Delete => {
6610            ed.push_undo();
6611            delete_block_contents(ed, top, bot, left, right);
6612            if !yank.is_empty() {
6613                ed.record_yank_to_host(yank.clone());
6614                ed.record_delete(yank, false);
6615            }
6616            ed.vim.mode = Mode::Normal;
6617            ed.jump_cursor(top, left);
6618        }
6619        Operator::Change => {
6620            ed.push_undo();
6621            delete_block_contents(ed, top, bot, left, right);
6622            if !yank.is_empty() {
6623                ed.record_yank_to_host(yank.clone());
6624                ed.record_delete(yank, false);
6625            }
6626            ed.jump_cursor(top, left);
6627            begin_insert_noundo(
6628                ed,
6629                1,
6630                InsertReason::BlockChange {
6631                    top,
6632                    bot,
6633                    col: left,
6634                },
6635            );
6636        }
6637        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {
6638            ed.push_undo();
6639            transform_block_case(ed, op, top, bot, left, right);
6640            ed.vim.mode = Mode::Normal;
6641            ed.jump_cursor(top, left);
6642        }
6643        Operator::Indent | Operator::Outdent => {
6644            // VisualBlock `>` / `<` falls back to linewise indent over
6645            // the block's row range — vim does the same (column-wise
6646            // indent/outdent doesn't make sense).
6647            ed.push_undo();
6648            if op == Operator::Indent {
6649                indent_rows(ed, top, bot, count.max(1));
6650            } else {
6651                outdent_rows(ed, top, bot, count.max(1));
6652            }
6653            ed.vim.mode = Mode::Normal;
6654        }
6655        Operator::Fold => unreachable!("Visual zf takes its own path"),
6656        Operator::Reflow => {
6657            // Reflow over the block falls back to linewise reflow over
6658            // the row range — column slicing for `gq` doesn't make
6659            // sense.
6660            ed.push_undo();
6661            reflow_rows(ed, top, bot);
6662            ed.vim.mode = Mode::Normal;
6663        }
6664        Operator::ReflowKeepCursor => {
6665            // `gw` over a block: same fallback as `gq` but restore cursor.
6666            let saved = ed.cursor();
6667            ed.push_undo();
6668            let (before, after) = reflow_rows_keep_cursor(ed, top, bot);
6669            let (new_row, new_col) = reflow_keep_cursor(top, saved.0, saved.1, &before, &after);
6670            buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
6671            ed.push_buffer_cursor_to_textarea();
6672            ed.vim.mode = Mode::Normal;
6673        }
6674        Operator::AutoIndent => {
6675            // AutoIndent over the block falls back to linewise
6676            // auto-indent over the row range.
6677            ed.push_undo();
6678            auto_indent_rows(ed, top, bot);
6679            ed.vim.mode = Mode::Normal;
6680        }
6681        // Filter is dispatched through Editor::filter_range, not here.
6682        Operator::Filter => {}
6683        // Comment is dispatched through the app layer (engine_actions.rs), not here.
6684        Operator::Comment => {}
6685    }
6686}
6687
6688/// In-place case transform over the rectangular block
6689/// `(top..=bot, left..=right)`. Rows shorter than `left` are left
6690/// untouched — vim behaves the same way (ragged blocks).
6691fn transform_block_case<H: crate::types::Host>(
6692    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6693    op: Operator,
6694    top: usize,
6695    bot: usize,
6696    left: usize,
6697    right: usize,
6698) {
6699    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
6700    for r in top..=bot.min(lines.len().saturating_sub(1)) {
6701        let chars: Vec<char> = lines[r].chars().collect();
6702        if left >= chars.len() {
6703            continue;
6704        }
6705        let end = (right + 1).min(chars.len());
6706        let head: String = chars[..left].iter().collect();
6707        let mid: String = chars[left..end].iter().collect();
6708        let tail: String = chars[end..].iter().collect();
6709        let transformed = match op {
6710            Operator::Uppercase => mid.to_uppercase(),
6711            Operator::Lowercase => mid.to_lowercase(),
6712            Operator::ToggleCase => toggle_case_str(&mid),
6713            Operator::Rot13 => rot13_str(&mid),
6714            _ => mid,
6715        };
6716        lines[r] = format!("{head}{transformed}{tail}");
6717    }
6718    let saved_yank = ed.yank().to_string();
6719    let saved_linewise = ed.vim.yank_linewise;
6720    ed.restore(lines, (top, left));
6721    ed.set_yank(saved_yank);
6722    ed.vim.yank_linewise = saved_linewise;
6723}
6724
6725fn block_yank<H: crate::types::Host>(
6726    ed: &Editor<hjkl_buffer::Buffer, H>,
6727    top: usize,
6728    bot: usize,
6729    left: usize,
6730    right: usize,
6731) -> String {
6732    let rope = crate::types::Query::rope(&ed.buffer);
6733    let n = rope.len_lines();
6734    let mut rows: Vec<String> = Vec::new();
6735    for r in top..=bot {
6736        if r >= n {
6737            break;
6738        }
6739        let line = rope_line_to_str(&rope, r);
6740        let chars: Vec<char> = line.chars().collect();
6741        let end = (right + 1).min(chars.len());
6742        if left >= chars.len() {
6743            rows.push(String::new());
6744        } else {
6745            rows.push(chars[left..end].iter().collect());
6746        }
6747    }
6748    rows.join("\n")
6749}
6750
6751fn delete_block_contents<H: crate::types::Host>(
6752    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6753    top: usize,
6754    bot: usize,
6755    left: usize,
6756    right: usize,
6757) {
6758    use hjkl_buffer::{Edit, MotionKind, Position};
6759    ed.sync_buffer_content_from_textarea();
6760    let last_row = bot.min(buf_row_count(&ed.buffer).saturating_sub(1));
6761    if last_row < top {
6762        return;
6763    }
6764    ed.mutate_edit(Edit::DeleteRange {
6765        start: Position::new(top, left),
6766        end: Position::new(last_row, right),
6767        kind: MotionKind::Block,
6768    });
6769    ed.push_buffer_cursor_to_textarea();
6770}
6771
6772/// Replace each character cell in the block with `ch`.
6773pub(crate) fn block_replace<H: crate::types::Host>(
6774    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6775    ch: char,
6776) {
6777    let (top, bot, left, right) = block_bounds(ed);
6778    ed.push_undo();
6779    ed.sync_buffer_content_from_textarea();
6780    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
6781    for r in top..=bot.min(lines.len().saturating_sub(1)) {
6782        let chars: Vec<char> = lines[r].chars().collect();
6783        if left >= chars.len() {
6784            continue;
6785        }
6786        let end = (right + 1).min(chars.len());
6787        let before: String = chars[..left].iter().collect();
6788        let middle: String = std::iter::repeat_n(ch, end - left).collect();
6789        let after: String = chars[end..].iter().collect();
6790        lines[r] = format!("{before}{middle}{after}");
6791    }
6792    reset_textarea_lines(ed, lines);
6793    ed.vim.mode = Mode::Normal;
6794    ed.jump_cursor(top, left);
6795}
6796
6797/// Replace buffer content with `lines` while preserving the cursor.
6798/// Used by indent / outdent / block_replace to wholesale rewrite
6799/// rows without going through the per-edit funnel.
6800fn reset_textarea_lines<H: crate::types::Host>(
6801    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6802    lines: Vec<String>,
6803) {
6804    let cursor = ed.cursor();
6805    crate::types::BufferEdit::replace_all(&mut ed.buffer, &lines.join("\n"));
6806    buf_set_cursor_rc(&mut ed.buffer, cursor.0, cursor.1);
6807    ed.mark_content_dirty();
6808}
6809
6810// ─── Visual-line helpers ───────────────────────────────────────────────────
6811
6812// ─── Text-object range computation ─────────────────────────────────────────
6813
6814/// Cursor position as `(row, col)`.
6815type Pos = (usize, usize);
6816
6817/// Returns `(start, end, kind)` where `end` is *exclusive* (one past the
6818/// last character to act on). `kind` is `Linewise` for line-oriented text
6819/// objects like paragraphs and `Exclusive` otherwise.
6820pub(crate) fn text_object_range<H: crate::types::Host>(
6821    ed: &Editor<hjkl_buffer::Buffer, H>,
6822    obj: TextObject,
6823    inner: bool,
6824    count: usize,
6825) -> Option<(Pos, Pos, RangeKind)> {
6826    match obj {
6827        TextObject::Word { big } => {
6828            word_text_object(ed, inner, big, count).map(|(s, e)| (s, e, RangeKind::Exclusive))
6829        }
6830        TextObject::Quote(q) => {
6831            quote_text_object(ed, q, inner).map(|(s, e)| (s, e, RangeKind::Exclusive))
6832        }
6833        TextObject::Bracket(open) => bracket_text_object(ed, open, inner, count),
6834        TextObject::Paragraph => {
6835            paragraph_text_object(ed, inner, count).map(|(s, e)| (s, e, RangeKind::Linewise))
6836        }
6837        TextObject::XmlTag => tag_text_object(ed, inner).map(|(s, e)| (s, e, RangeKind::Exclusive)),
6838        TextObject::Sentence => {
6839            sentence_text_object(ed, inner, count).map(|(s, e)| (s, e, RangeKind::Exclusive))
6840        }
6841    }
6842}
6843
6844/// `(` / `)` — walk to the next sentence boundary in `forward` direction.
6845/// Returns `(row, col)` of the boundary's first non-whitespace cell, or
6846/// `None` when already at the buffer's edge in that direction.
6847fn sentence_boundary<H: crate::types::Host>(
6848    ed: &Editor<hjkl_buffer::Buffer, H>,
6849    forward: bool,
6850) -> Option<(usize, usize)> {
6851    let rope = crate::types::Query::rope(&ed.buffer);
6852    let n_lines = rope.len_lines();
6853    if n_lines == 0 {
6854        return None;
6855    }
6856    // Per-line char counts (excluding trailing \n) for pos↔idx conversion.
6857    let line_lens: Vec<usize> = (0..n_lines)
6858        .map(|r| rope_line_to_str(&rope, r).chars().count())
6859        .collect();
6860    let pos_to_idx = |pos: (usize, usize)| -> usize {
6861        let idx: usize = line_lens.iter().take(pos.0).map(|&len| len + 1).sum();
6862        idx + pos.1
6863    };
6864    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
6865        for (r, &len) in line_lens.iter().enumerate() {
6866            if idx <= len {
6867                return (r, idx);
6868            }
6869            idx -= len + 1;
6870        }
6871        let last = n_lines.saturating_sub(1);
6872        (last, line_lens[last])
6873    };
6874    // Build flat char vector: rope chars already include \n between lines.
6875    // ropey's last line has no trailing \n; intermediate ones do.
6876    let mut chars: Vec<char> = rope.chars().collect();
6877    // Strip a trailing \n if ropey emitted one on the final line.
6878    if chars.last() == Some(&'\n') {
6879        chars.pop();
6880    }
6881    if chars.is_empty() {
6882        return None;
6883    }
6884    let total = chars.len();
6885    let cursor_idx = pos_to_idx(ed.cursor()).min(total - 1);
6886    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
6887
6888    if forward {
6889        // Walk forward looking for a terminator run followed by
6890        // whitespace; land on the first non-whitespace cell after.
6891        let mut i = cursor_idx + 1;
6892        while i < total {
6893            if is_terminator(chars[i]) {
6894                while i + 1 < total && is_terminator(chars[i + 1]) {
6895                    i += 1;
6896                }
6897                if i + 1 >= total {
6898                    return None;
6899                }
6900                if chars[i + 1].is_whitespace() {
6901                    let mut j = i + 1;
6902                    while j < total && chars[j].is_whitespace() {
6903                        j += 1;
6904                    }
6905                    if j >= total {
6906                        return None;
6907                    }
6908                    return Some(idx_to_pos(j));
6909                }
6910            }
6911            i += 1;
6912        }
6913        None
6914    } else {
6915        // Walk backward to find the start of the current sentence (if
6916        // we're already at the start, jump to the previous sentence's
6917        // start instead).
6918        let find_start = |from: usize| -> Option<usize> {
6919            let mut start = from;
6920            while start > 0 {
6921                let prev = chars[start - 1];
6922                if prev.is_whitespace() {
6923                    let mut k = start - 1;
6924                    while k > 0 && chars[k - 1].is_whitespace() {
6925                        k -= 1;
6926                    }
6927                    if k > 0 && is_terminator(chars[k - 1]) {
6928                        break;
6929                    }
6930                }
6931                start -= 1;
6932            }
6933            while start < total && chars[start].is_whitespace() {
6934                start += 1;
6935            }
6936            (start < total).then_some(start)
6937        };
6938        let current_start = find_start(cursor_idx)?;
6939        if current_start < cursor_idx {
6940            return Some(idx_to_pos(current_start));
6941        }
6942        // Already at the sentence start — step over the boundary into
6943        // the previous sentence and find its start.
6944        let mut k = current_start;
6945        while k > 0 && chars[k - 1].is_whitespace() {
6946            k -= 1;
6947        }
6948        if k == 0 {
6949            return None;
6950        }
6951        let prev_start = find_start(k - 1)?;
6952        Some(idx_to_pos(prev_start))
6953    }
6954}
6955
6956/// `is` / `as` — sentence: text up to and including the next sentence
6957/// terminator (`.`, `?`, `!`). Vim treats `.`/`?`/`!` followed by
6958/// whitespace (or end-of-line) as a boundary; runs of consecutive
6959/// terminators stay attached to the same sentence. `as` extends to
6960/// include trailing whitespace; `is` does not.
6961fn sentence_text_object<H: crate::types::Host>(
6962    ed: &Editor<hjkl_buffer::Buffer, H>,
6963    inner: bool,
6964    count: usize,
6965) -> Option<((usize, usize), (usize, usize))> {
6966    let count = count.max(1);
6967    let rope = crate::types::Query::rope(&ed.buffer);
6968    let n_lines = rope.len_lines();
6969    if n_lines == 0 {
6970        return None;
6971    }
6972    // Flatten the buffer so a sentence can span lines (vim's behaviour).
6973    // Newlines count as whitespace for boundary detection.
6974    let line_lens: Vec<usize> = (0..n_lines)
6975        .map(|r| rope_line_to_str(&rope, r).chars().count())
6976        .collect();
6977    let pos_to_idx = |pos: (usize, usize)| -> usize {
6978        let idx: usize = line_lens.iter().take(pos.0).map(|&len| len + 1).sum();
6979        idx + pos.1
6980    };
6981    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
6982        for (r, &len) in line_lens.iter().enumerate() {
6983            if idx <= len {
6984                return (r, idx);
6985            }
6986            idx -= len + 1;
6987        }
6988        let last = n_lines.saturating_sub(1);
6989        (last, line_lens[last])
6990    };
6991    let mut chars: Vec<char> = rope.chars().collect();
6992    if chars.last() == Some(&'\n') {
6993        chars.pop();
6994    }
6995    if chars.is_empty() {
6996        return None;
6997    }
6998
6999    let cursor_idx = pos_to_idx(ed.cursor()).min(chars.len() - 1);
7000    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
7001
7002    // Walk backward from cursor to find the start of the current
7003    // sentence. A boundary is: whitespace immediately after a run of
7004    // terminators (or start-of-buffer).
7005    let mut start = cursor_idx;
7006    while start > 0 {
7007        let prev = chars[start - 1];
7008        if prev.is_whitespace() {
7009            // Check if the whitespace follows a terminator — if so,
7010            // we've crossed a sentence boundary; the sentence begins
7011            // at the first non-whitespace cell *after* this run.
7012            let mut k = start - 1;
7013            while k > 0 && chars[k - 1].is_whitespace() {
7014                k -= 1;
7015            }
7016            if k > 0 && is_terminator(chars[k - 1]) {
7017                break;
7018            }
7019        }
7020        start -= 1;
7021    }
7022    // Skip leading whitespace (vim doesn't include it in the
7023    // sentence body).
7024    while start < chars.len() && chars[start].is_whitespace() {
7025        start += 1;
7026    }
7027    if start >= chars.len() {
7028        return None;
7029    }
7030
7031    // Walk forward to the sentence end (last terminator before the
7032    // next whitespace boundary).
7033    let mut end = start;
7034    while end < chars.len() {
7035        if is_terminator(chars[end]) {
7036            // Consume any consecutive terminators (e.g. `?!`).
7037            while end + 1 < chars.len() && is_terminator(chars[end + 1]) {
7038                end += 1;
7039            }
7040            // If followed by whitespace or end-of-buffer, that's the
7041            // boundary.
7042            if end + 1 >= chars.len() || chars[end + 1].is_whitespace() {
7043                break;
7044            }
7045        }
7046        end += 1;
7047    }
7048    // `Nis` / `Nas`: extend across `count - 1` further sentences, skipping the
7049    // whitespace between each and walking to the next sentence's end.
7050    let mut rem = count - 1;
7051    while rem > 0 {
7052        let mut s = end + 1;
7053        while s < chars.len() && chars[s].is_whitespace() {
7054            s += 1;
7055        }
7056        if s >= chars.len() {
7057            break;
7058        }
7059        let mut e = s;
7060        while e < chars.len() {
7061            if is_terminator(chars[e]) {
7062                while e + 1 < chars.len() && is_terminator(chars[e + 1]) {
7063                    e += 1;
7064                }
7065                if e + 1 >= chars.len() || chars[e + 1].is_whitespace() {
7066                    break;
7067                }
7068            }
7069            e += 1;
7070        }
7071        end = e;
7072        rem -= 1;
7073    }
7074    // Inclusive end → exclusive end_idx.
7075    let end_idx = (end + 1).min(chars.len());
7076
7077    let final_end = if inner {
7078        end_idx
7079    } else {
7080        // `as`: include trailing whitespace (but stop before the next
7081        // newline so we don't gobble a paragraph break — vim keeps
7082        // sentences within a paragraph for the trailing-ws extension).
7083        let mut e = end_idx;
7084        while e < chars.len() && chars[e].is_whitespace() && chars[e] != '\n' {
7085            e += 1;
7086        }
7087        e
7088    };
7089
7090    Some((idx_to_pos(start), idx_to_pos(final_end)))
7091}
7092
7093/// `it` / `at` — XML tag pair text object. Builds a flat char index of
7094/// the buffer, walks `<...>` tokens to pair tags via a stack, and
7095/// returns the innermost pair containing the cursor.
7096fn tag_text_object<H: crate::types::Host>(
7097    ed: &Editor<hjkl_buffer::Buffer, H>,
7098    inner: bool,
7099) -> Option<((usize, usize), (usize, usize))> {
7100    let rope = crate::types::Query::rope(&ed.buffer);
7101    let n_lines = rope.len_lines();
7102    if n_lines == 0 {
7103        return None;
7104    }
7105    // Flatten char positions so we can compare cursor against tag
7106    // ranges without per-row arithmetic. `\n` between lines counts as
7107    // a single char.
7108    let line_lens: Vec<usize> = (0..n_lines)
7109        .map(|r| rope_line_to_str(&rope, r).chars().count())
7110        .collect();
7111    let pos_to_idx = |pos: (usize, usize)| -> usize {
7112        let idx: usize = line_lens.iter().take(pos.0).map(|&len| len + 1).sum();
7113        idx + pos.1
7114    };
7115    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
7116        for (r, &len) in line_lens.iter().enumerate() {
7117            if idx <= len {
7118                return (r, idx);
7119            }
7120            idx -= len + 1;
7121        }
7122        let last = n_lines.saturating_sub(1);
7123        (last, line_lens[last])
7124    };
7125    let mut chars: Vec<char> = rope.chars().collect();
7126    if chars.last() == Some(&'\n') {
7127        chars.pop();
7128    }
7129    let cursor_idx = pos_to_idx(ed.cursor());
7130
7131    // Walk `<...>` tokens. Track open tags on a stack; on a matching
7132    // close pop and consider the pair a candidate when the cursor lies
7133    // inside its content range. Innermost wins (replace whenever a
7134    // tighter range turns up). Also track the first complete pair that
7135    // starts at or after the cursor so we can fall back to a forward
7136    // scan (targets.vim-style) when the cursor isn't inside any tag.
7137    let mut stack: Vec<(usize, usize, String)> = Vec::new(); // (open_start, content_start, name)
7138    let mut innermost: Option<(usize, usize, usize, usize)> = None;
7139    let mut next_after: Option<(usize, usize, usize, usize)> = None;
7140    let mut i = 0;
7141    while i < chars.len() {
7142        if chars[i] != '<' {
7143            i += 1;
7144            continue;
7145        }
7146        let mut j = i + 1;
7147        while j < chars.len() && chars[j] != '>' {
7148            j += 1;
7149        }
7150        if j >= chars.len() {
7151            break;
7152        }
7153        let inside: String = chars[i + 1..j].iter().collect();
7154        let close_end = j + 1;
7155        let trimmed = inside.trim();
7156        if trimmed.starts_with('!') || trimmed.starts_with('?') {
7157            i = close_end;
7158            continue;
7159        }
7160        if let Some(rest) = trimmed.strip_prefix('/') {
7161            let name = rest.split_whitespace().next().unwrap_or("").to_string();
7162            if !name.is_empty()
7163                && let Some(stack_idx) = stack.iter().rposition(|(_, _, n)| *n == name)
7164            {
7165                let (open_start, content_start, _) = stack[stack_idx].clone();
7166                stack.truncate(stack_idx);
7167                let content_end = i;
7168                let candidate = (open_start, content_start, content_end, close_end);
7169                // A pair encloses the cursor when the cursor lies anywhere
7170                // within the whole pair span — including ON the open or close
7171                // tag itself (vim `it`/`at` operate on the tag under the
7172                // cursor, not just its content). Innermost (tightest span)
7173                // wins; closes are seen innermost-first so the first enclosing
7174                // candidate is already the tightest.
7175                if cursor_idx >= open_start && cursor_idx < close_end {
7176                    innermost = match innermost {
7177                        Some((os, _, _, ce)) if os <= open_start && close_end <= ce => {
7178                            Some(candidate)
7179                        }
7180                        None => Some(candidate),
7181                        existing => existing,
7182                    };
7183                } else if open_start >= cursor_idx && next_after.is_none() {
7184                    next_after = Some(candidate);
7185                }
7186            }
7187        } else if !trimmed.ends_with('/') {
7188            let name: String = trimmed
7189                .split(|c: char| c.is_whitespace() || c == '/')
7190                .next()
7191                .unwrap_or("")
7192                .to_string();
7193            if !name.is_empty() {
7194                stack.push((i, close_end, name));
7195            }
7196        }
7197        i = close_end;
7198    }
7199
7200    let (open_start, content_start, content_end, close_end) = innermost.or(next_after)?;
7201    if inner {
7202        Some((idx_to_pos(content_start), idx_to_pos(content_end)))
7203    } else {
7204        Some((idx_to_pos(open_start), idx_to_pos(close_end)))
7205    }
7206}
7207
7208fn is_wordchar(c: char) -> bool {
7209    c.is_alphanumeric() || c == '_'
7210}
7211
7212// `is_keyword_char` lives in hjkl-buffer (used by word motions);
7213// engine re-uses it via `hjkl_buffer::is_keyword_char` so there's
7214// one parser, one default, one bug surface.
7215pub(crate) use hjkl_buffer::is_keyword_char;
7216
7217pub(crate) fn abbrev_kind(lhs: &str, iskeyword: &str) -> AbbrevKind {
7218    let chars: Vec<char> = lhs.chars().collect();
7219    if chars.is_empty() {
7220        return AbbrevKind::NonKw;
7221    }
7222    let last = *chars.last().unwrap();
7223    let last_is_kw = is_keyword_char(last, iskeyword);
7224    if !last_is_kw {
7225        return AbbrevKind::NonKw;
7226    }
7227    // last is keyword — check if all chars are keyword
7228    let all_kw = chars.iter().all(|&c| is_keyword_char(c, iskeyword));
7229    if all_kw {
7230        AbbrevKind::Full
7231    } else {
7232        AbbrevKind::End
7233    }
7234}
7235
7236/// Try to match and expand an abbreviation given the text before the cursor.
7237///
7238/// # Parameters
7239/// - `abbrevs` — the active abbreviation table (insert-mode entries).
7240/// - `line_before` — the text on the current line *before* the cursor (char slice).
7241/// - `mincol` — first column index (0-based, char-indexed) that belongs to the
7242///   current insert session on the **same row as the cursor**.  Chars before
7243///   `mincol` were in the buffer before insert mode started and must NOT be
7244///   consumed as part of the lhs.  When the cursor is on a different row than
7245///   `start_row`, `mincol` is treated as 0 (the entire line was typed in this
7246///   session).
7247/// - `trigger` — what the user did (typed a non-kw char, pressed CR/Esc/C-]).
7248/// - `iskeyword` — the active iskeyword spec string.
7249///
7250/// Returns `Some((lhs_char_len, rhs))` on a match, where `lhs_char_len` is the
7251/// number of characters to delete before the cursor (the lhs), and `rhs` is the
7252/// text to insert in their place.  Returns `None` when no abbreviation matches.
7253pub(crate) fn try_abbrev_expand(
7254    abbrevs: &[Abbrev],
7255    line_before: &str,
7256    mincol: usize,
7257    trigger: AbbrevTrigger,
7258    iskeyword: &str,
7259) -> Option<(usize, String)> {
7260    let chars: Vec<char> = line_before.chars().collect();
7261    let cursor_col = chars.len(); // col of the cursor (0-based)
7262
7263    for abbrev in abbrevs {
7264        if !abbrev.insert {
7265            continue;
7266        }
7267        let lhs_chars: Vec<char> = abbrev.lhs.chars().collect();
7268        if lhs_chars.is_empty() {
7269            continue;
7270        }
7271        let lhs_len = lhs_chars.len();
7272
7273        // Determine the lhs type.
7274        let kind = abbrev_kind(&abbrev.lhs, iskeyword);
7275
7276        // Trigger rules by lhs type.
7277        match kind {
7278            AbbrevKind::Full | AbbrevKind::End => {
7279                // full-id / end-id: trigger char must be a NON-keyword char
7280                // (space, punctuation, CR, Esc, C-]).
7281                let trigger_char_is_kw = match trigger {
7282                    AbbrevTrigger::NonKeyword(c) => is_keyword_char(c, iskeyword),
7283                    AbbrevTrigger::CtrlBracket | AbbrevTrigger::Cr | AbbrevTrigger::Esc => false,
7284                };
7285                if trigger_char_is_kw {
7286                    // A keyword trigger char would extend the word — no expand.
7287                    continue;
7288                }
7289            }
7290            AbbrevKind::NonKw => {
7291                // non-id: only expand on CR, Esc, C-].  NOT on regular typed chars.
7292                match trigger {
7293                    AbbrevTrigger::Cr | AbbrevTrigger::Esc | AbbrevTrigger::CtrlBracket => {}
7294                    AbbrevTrigger::NonKeyword(_) => continue,
7295                }
7296            }
7297        }
7298
7299        // Check that the text before the cursor ends with the lhs.
7300        if cursor_col < lhs_len {
7301            continue;
7302        }
7303        let lhs_start_col = cursor_col - lhs_len;
7304
7305        // Enforce mincol: the lhs must not start before the insert-start column.
7306        if lhs_start_col < mincol {
7307            continue;
7308        }
7309
7310        // Compare chars.
7311        let text_slice: &[char] = &chars[lhs_start_col..cursor_col];
7312        if text_slice != lhs_chars.as_slice() {
7313            continue;
7314        }
7315
7316        // Check "front" rule: the char immediately before the lhs.
7317        if lhs_start_col > 0 {
7318            let ch_before = chars[lhs_start_col - 1];
7319            match kind {
7320                AbbrevKind::Full => {
7321                    // full-id: char before lhs must be a non-keyword char.
7322                    // Single-char full-id exception: if the char before is a
7323                    // non-keyword char that is NOT space/tab, it is NOT recognised
7324                    // (vim `:h abbreviations`: "A word in front of a full-id abbrev
7325                    // is a non-keyword char; but a single char abbrev is not
7326                    // recognised after a non-blank, non-keyword char").
7327                    // Actually vim's rule: full-id is not recognised if the char
7328                    // before is a NON-keyword char other than space/tab AND the lhs
7329                    // is a single keyword char. For multi-char full-id the rule is
7330                    // just "char before must be non-keyword".
7331                    if is_keyword_char(ch_before, iskeyword) {
7332                        continue; // char before is keyword → lhs is part of a longer word
7333                    }
7334                    if lhs_len == 1 && ch_before != ' ' && ch_before != '\t' {
7335                        // single-char full-id: non-blank non-keyword before → skip
7336                        continue;
7337                    }
7338                }
7339                AbbrevKind::End => {
7340                    // end-id: no constraint on the char before (any char is fine,
7341                    // including keyword chars — the non-keyword prefix of the lhs
7342                    // acts as the boundary).
7343                }
7344                AbbrevKind::NonKw => {
7345                    // non-id: the char before the lhs must be blank (space/tab) or
7346                    // it must be the start of the typed portion (mincol boundary).
7347                    if ch_before != ' ' && ch_before != '\t' {
7348                        continue;
7349                    }
7350                }
7351            }
7352        }
7353        // lhs_start_col == 0 means the lhs starts at the very beginning of the
7354        // line (or at the insert-start position); all types accept this.
7355
7356        return Some((lhs_len, abbrev.rhs.clone()));
7357    }
7358
7359    None
7360}
7361
7362/// Check abbreviations and apply the expansion if a match is found.
7363///
7364/// Reads the current cursor position and the text before it, calls
7365/// `try_abbrev_expand`, and if a match is found, deletes the `lhs` chars
7366/// and inserts the `rhs`. Returns `true` if an expansion was applied.
7367///
7368/// `trigger` is what the user did; the trigger char itself is NOT inserted
7369/// here — the caller inserts it (or not, in the case of `C-]`).
7370pub(crate) fn check_and_apply_abbrev<H: crate::types::Host>(
7371    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7372    trigger: AbbrevTrigger,
7373) -> bool {
7374    use hjkl_buffer::{Edit, Position};
7375
7376    // Collect the data we need without holding borrows.
7377    let cursor = buf_cursor_pos(&ed.buffer);
7378    let row = cursor.row;
7379    let col = cursor.col;
7380    let line_before: String = {
7381        let line = buf_line(&ed.buffer, row).unwrap_or_default();
7382        line.chars().take(col).collect()
7383    };
7384    let (mincol, on_start_row) = if let Some(ref s) = ed.vim.insert_session {
7385        if row == s.start_row {
7386            (s.start_col, true)
7387        } else {
7388            (0, false)
7389        }
7390    } else {
7391        (0, false)
7392    };
7393    // If cursor is before the insert start column on the same row, no lhs possible.
7394    if on_start_row && col <= mincol {
7395        return false;
7396    }
7397
7398    let iskeyword = ed.settings.iskeyword.clone();
7399    let abbrevs = ed.vim.abbrevs.clone();
7400
7401    let Some((lhs_len, rhs)) =
7402        try_abbrev_expand(&abbrevs, &line_before, mincol, trigger, &iskeyword)
7403    else {
7404        return false;
7405    };
7406
7407    // Delete `lhs_len` chars before the cursor.
7408    let lhs_start = col.saturating_sub(lhs_len);
7409    if lhs_len > 0 {
7410        ed.mutate_edit(Edit::DeleteRange {
7411            start: Position::new(row, lhs_start),
7412            end: Position::new(row, col),
7413            kind: hjkl_buffer::MotionKind::Char,
7414        });
7415    }
7416
7417    // Insert rhs at the (now updated) cursor position.
7418    let insert_pos = Position::new(row, lhs_start);
7419    if !rhs.is_empty() {
7420        ed.mutate_edit(Edit::InsertStr {
7421            at: insert_pos,
7422            text: rhs.clone(),
7423        });
7424    }
7425
7426    // Move cursor to end of inserted rhs.
7427    let new_col = lhs_start + rhs.chars().count();
7428    buf_set_cursor_rc(&mut ed.buffer, row, new_col);
7429    ed.push_buffer_cursor_to_textarea();
7430
7431    true
7432}
7433
7434fn word_text_object<H: crate::types::Host>(
7435    ed: &Editor<hjkl_buffer::Buffer, H>,
7436    inner: bool,
7437    big: bool,
7438    count: usize,
7439) -> Option<((usize, usize), (usize, usize))> {
7440    let count = count.max(1);
7441    let (row, col) = ed.cursor();
7442    let line = buf_line(&ed.buffer, row)?;
7443    let chars: Vec<char> = line.chars().collect();
7444    if chars.is_empty() {
7445        return None;
7446    }
7447    let len = chars.len();
7448    let at = col.min(len.saturating_sub(1));
7449    let classify = |c: char| -> u8 {
7450        if c.is_whitespace() {
7451            0
7452        } else if big || is_wordchar(c) {
7453            1
7454        } else {
7455            2
7456        }
7457    };
7458    let cls = classify(chars[at]);
7459    let mut start = at;
7460    while start > 0 && classify(chars[start - 1]) == cls {
7461        start -= 1;
7462    }
7463    let mut end = at;
7464    while end + 1 < len && classify(chars[end + 1]) == cls {
7465        end += 1;
7466    }
7467    // Columns are char indices — the convention used by the operator
7468    // pipeline (`cut_vim_range` / `read_vim_range`) and the visual-mode
7469    // extend path. Pre-0.33.5 this converted to BYTE offsets, which those
7470    // consumers re-interpreted as char columns — `diw` / `viw` acted on
7471    // the wrong span whenever the line held multibyte text.
7472    let mut start_col = start;
7473    // Exclusive end: char index AFTER the last-included char. Assigned in each
7474    // branch below (inner / aw-on-whitespace / aw-on-word).
7475    let end_col;
7476    if inner {
7477        // `Niw` selects N alternating runs (word / punct / whitespace), so
7478        // extend the end over `count - 1` further runs.
7479        let mut rem = count - 1;
7480        while rem > 0 && end + 1 < len {
7481            let next_kind = classify(chars[end + 1]);
7482            end += 1;
7483            while end + 1 < len && classify(chars[end + 1]) == next_kind {
7484                end += 1;
7485            }
7486            rem -= 1;
7487        }
7488        end_col = end + 1;
7489    } else if cls == 0 {
7490        // `aw` with the cursor on whitespace: vim selects the whitespace run
7491        // plus the FOLLOWING word (`:help aw`). `start..end` already covers the
7492        // whitespace run; consume `count` following non-blank runs, including
7493        // any whitespace between them.
7494        let mut e = end;
7495        let mut rem = count;
7496        while rem > 0 && e + 1 < len {
7497            // Skip whitespace to the next word (no-op right after the initial
7498            // run, relevant only for count > 1).
7499            while e + 1 < len && chars[e + 1].is_whitespace() {
7500                e += 1;
7501            }
7502            if e + 1 >= len {
7503                break;
7504            }
7505            // Consume the word (non-blank run).
7506            e += 1;
7507            let k = classify(chars[e]);
7508            while e + 1 < len && classify(chars[e + 1]) == k {
7509                e += 1;
7510            }
7511            rem -= 1;
7512        }
7513        end_col = e + 1;
7514    } else {
7515        // `Naw` with the cursor on a word — include N non-blank runs plus the
7516        // whitespace between them, then the trailing whitespace after the last
7517        // run; if the last run has no trailing whitespace, absorb the leading
7518        // whitespace before the first instead (vim `:help aw`).
7519        let mut e = end;
7520        let mut words_done = 1;
7521        let mut included_trailing = false;
7522        loop {
7523            let mut t = e + 1;
7524            let mut got_ws = false;
7525            while t < len && chars[t].is_whitespace() {
7526                got_ws = true;
7527                t += 1;
7528            }
7529            if words_done == count {
7530                if got_ws {
7531                    e = t - 1;
7532                    included_trailing = true;
7533                }
7534                break;
7535            }
7536            if t >= len {
7537                break; // no further word to include
7538            }
7539            // Advance onto the next non-blank run and consume it.
7540            e = t;
7541            let k = classify(chars[e]);
7542            while e + 1 < len && classify(chars[e + 1]) == k {
7543                e += 1;
7544            }
7545            words_done += 1;
7546        }
7547        end_col = e + 1;
7548        if !included_trailing {
7549            let mut s = start;
7550            while s > 0 && chars[s - 1].is_whitespace() {
7551                s -= 1;
7552            }
7553            start_col = s;
7554        }
7555    }
7556    Some(((row, start_col), (row, end_col)))
7557}
7558
7559fn quote_text_object<H: crate::types::Host>(
7560    ed: &Editor<hjkl_buffer::Buffer, H>,
7561    q: char,
7562    inner: bool,
7563) -> Option<((usize, usize), (usize, usize))> {
7564    let (row, col) = ed.cursor();
7565    let line = buf_line(&ed.buffer, row)?;
7566    // All columns here are CHAR indices — both the cursor `col` and the
7567    // returned range. Pre-0.33.5 this scanned BYTE offsets and compared
7568    // them against the char-indexed cursor, so `di"` / `ci"` picked the
7569    // wrong pair (and cut the wrong span) on lines with multibyte text.
7570    let chars: Vec<char> = line.chars().collect();
7571    // Find opening and closing quote on the same line.
7572    let mut positions: Vec<usize> = Vec::new();
7573    for (i, &c) in chars.iter().enumerate() {
7574        if c == q {
7575            positions.push(i);
7576        }
7577    }
7578    if positions.len() < 2 {
7579        return None;
7580    }
7581    let mut open_idx: Option<usize> = None;
7582    let mut close_idx: Option<usize> = None;
7583    for pair in positions.chunks(2) {
7584        if pair.len() < 2 {
7585            break;
7586        }
7587        if col >= pair[0] && col <= pair[1] {
7588            open_idx = Some(pair[0]);
7589            close_idx = Some(pair[1]);
7590            break;
7591        }
7592        if col < pair[0] {
7593            open_idx = Some(pair[0]);
7594            close_idx = Some(pair[1]);
7595            break;
7596        }
7597    }
7598    let open = open_idx?;
7599    let close = close_idx?;
7600    // End columns are *exclusive* — one past the last character to act on.
7601    if inner {
7602        if close <= open + 1 {
7603            return None;
7604        }
7605        Some(((row, open + 1), (row, close)))
7606    } else {
7607        // `da<q>` — "around" includes the surrounding whitespace on one
7608        // side: trailing whitespace if any exists after the closing quote;
7609        // otherwise leading whitespace before the opening quote. This
7610        // matches vim's `:help text-objects` behaviour and avoids leaving
7611        // a double-space when the quoted span sits mid-sentence.
7612        let after_close = close + 1; // char index after closing quote
7613        if after_close < chars.len() && chars[after_close].is_ascii_whitespace() {
7614            // Eat trailing whitespace run.
7615            let mut end = after_close;
7616            while end < chars.len() && chars[end].is_ascii_whitespace() {
7617                end += 1;
7618            }
7619            Some(((row, open), (row, end)))
7620        } else if open > 0 && chars[open - 1].is_ascii_whitespace() {
7621            // Eat leading whitespace run.
7622            let mut start = open;
7623            while start > 0 && chars[start - 1].is_ascii_whitespace() {
7624                start -= 1;
7625            }
7626            Some(((row, start), (row, close + 1)))
7627        } else {
7628            Some(((row, open), (row, close + 1)))
7629        }
7630    }
7631}
7632
7633fn bracket_text_object<H: crate::types::Host>(
7634    ed: &Editor<hjkl_buffer::Buffer, H>,
7635    open: char,
7636    inner: bool,
7637    count: usize,
7638) -> Option<(Pos, Pos, RangeKind)> {
7639    let close = match open {
7640        '(' => ')',
7641        '[' => ']',
7642        '{' => '}',
7643        '<' => '>',
7644        _ => return None,
7645    };
7646    let (row, col) = ed.cursor();
7647    let lines = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
7648    let lines = lines.as_slice();
7649    // If the cursor sits ON the closing bracket, vim anchors the pair to that
7650    // bracket: the close is at the cursor and the open is found by scanning
7651    // backward from just before it. Without this, `find_open_bracket` counts
7652    // the cursor's own close, increments depth, and skips past its matching
7653    // open — making `di}`/`di{`-on-`}` a silent no-op.
7654    let cursor_char = lines.get(row).and_then(|l| l.chars().nth(col));
7655    let (open_pos, close_pos) = if cursor_char == Some(close) {
7656        let open_pos = if col > 0 {
7657            find_open_bracket(lines, row, col - 1, open, close)
7658        } else if row > 0 {
7659            let pr = row - 1;
7660            let pc = lines[pr].chars().count().saturating_sub(1);
7661            find_open_bracket(lines, pr, pc, open, close)
7662        } else {
7663            None
7664        }?;
7665        (open_pos, (row, col))
7666    } else {
7667        // Walk backward from cursor to find unbalanced opening. When the
7668        // cursor isn't inside any pair, fall back to scanning forward for
7669        // the next opening bracket (targets.vim-style: `ci(` works when
7670        // cursor is before the `(` on the same line or below).
7671        let open_pos = find_open_bracket(lines, row, col, open, close)
7672            .or_else(|| find_next_open(lines, row, col, open))?;
7673        let close_pos = find_close_bracket(lines, open_pos.0, open_pos.1 + 1, open, close)?;
7674        (open_pos, close_pos)
7675    };
7676    // Count: `2i{` / `2a{` target the Nth enclosing pair. Expand outward from
7677    // the innermost pair, re-anchoring to each enclosing bracket in turn. Stop
7678    // early (and use the outermost found) if there aren't `count` levels.
7679    let (open_pos, close_pos) = {
7680        let (mut op, mut cp) = (open_pos, close_pos);
7681        for _ in 1..count.max(1) {
7682            let outer = if op.1 > 0 {
7683                find_open_bracket(lines, op.0, op.1 - 1, open, close)
7684            } else if op.0 > 0 {
7685                let pr = op.0 - 1;
7686                let pc = lines[pr].chars().count().saturating_sub(1);
7687                find_open_bracket(lines, pr, pc, open, close)
7688            } else {
7689                None
7690            };
7691            let Some(oo) = outer else { break };
7692            let Some(oc) = find_close_bracket(lines, oo.0, oo.1 + 1, open, close) else {
7693                break;
7694            };
7695            op = oo;
7696            cp = oc;
7697        }
7698        (op, cp)
7699    };
7700    // End positions are *exclusive*.
7701    if inner {
7702        // The inner region is the raw charwise span from just after `{` to just
7703        // before `}`. Returned as Exclusive: the VISUAL path uses it directly
7704        // (so `vi{` is charwise — `vi{d` → "{}"), while the OPERATOR path
7705        // (`di{`/`ci{`) applies vim's exclusive-motion adjustment in
7706        // `apply_op_with_text_object` to collapse a contentful multi-line block
7707        // to bare braces ("{\n}") or promote a clean one to linewise.
7708        // Inner start = position just after `{`. When `{` is the last char on
7709        // its line, the inner region begins at the start of the next line (so
7710        // the exclusive-motion adjustment can promote to linewise). `advance_pos`
7711        // stops at end-of-line, so wrap explicitly here.
7712        let open_line_len = lines[open_pos.0].chars().count();
7713        let inner_start = if open_pos.1 + 1 >= open_line_len && open_pos.0 + 1 < lines.len() {
7714            (open_pos.0 + 1, 0)
7715        } else {
7716            advance_pos(lines, open_pos)
7717        };
7718        // Empty inner (`{}` / `( )` degenerate) → empty range at the inner
7719        // start. `di{` then no-ops; `ci{` inserts at that point.
7720        if inner_start.0 > close_pos.0
7721            || (inner_start.0 == close_pos.0 && inner_start.1 >= close_pos.1)
7722        {
7723            return Some((inner_start, inner_start, RangeKind::Exclusive));
7724        }
7725        // Whitespace-only multi-line inner: vim's `di{` is a no-op and `ci{`
7726        // inserts at the inner start without deleting the whitespace. Model as
7727        // an empty range at the inner start. Detected when every char strictly
7728        // between the braces (excluding newlines) is a space/tab, and there is
7729        // at least one — an inner of only newlines (empty lines) does NOT count
7730        // and falls through to the normal collapse.
7731        if close_pos.0 > open_pos.0 {
7732            let mut saw_ws = false;
7733            let mut saw_other = false;
7734            for r in inner_start.0..=close_pos.0 {
7735                let line: Vec<char> = lines
7736                    .get(r)
7737                    .map(|l| l.chars().collect())
7738                    .unwrap_or_default();
7739                let from = if r == inner_start.0 { inner_start.1 } else { 0 };
7740                let to = if r == close_pos.0 {
7741                    close_pos.1
7742                } else {
7743                    line.len()
7744                };
7745                for &c in line
7746                    .iter()
7747                    .take(to.min(line.len()))
7748                    .skip(from.min(line.len()))
7749                {
7750                    if c == ' ' || c == '\t' {
7751                        saw_ws = true;
7752                    } else {
7753                        saw_other = true;
7754                    }
7755                }
7756            }
7757            if saw_ws && !saw_other {
7758                return Some((inner_start, inner_start, RangeKind::Exclusive));
7759            }
7760        }
7761        Some((inner_start, close_pos, RangeKind::Exclusive))
7762    } else {
7763        Some((
7764            open_pos,
7765            advance_pos(lines, close_pos),
7766            RangeKind::Exclusive,
7767        ))
7768    }
7769}
7770
7771fn find_open_bracket(
7772    lines: &[String],
7773    row: usize,
7774    col: usize,
7775    open: char,
7776    close: char,
7777) -> Option<(usize, usize)> {
7778    let mut depth: i32 = 0;
7779    let mut r = row;
7780    let mut c = col as isize;
7781    loop {
7782        let cur = &lines[r];
7783        let chars: Vec<char> = cur.chars().collect();
7784        // Clamp `c` to the line length: callers may seed `col` past
7785        // EOL on virtual-cursor lines (e.g., insert mode after `o`)
7786        // so direct indexing would panic on empty / short lines.
7787        if (c as usize) >= chars.len() {
7788            c = chars.len() as isize - 1;
7789        }
7790        while c >= 0 {
7791            let ch = chars[c as usize];
7792            if ch == close {
7793                depth += 1;
7794            } else if ch == open {
7795                if depth == 0 {
7796                    return Some((r, c as usize));
7797                }
7798                depth -= 1;
7799            }
7800            c -= 1;
7801        }
7802        if r == 0 {
7803            return None;
7804        }
7805        r -= 1;
7806        c = lines[r].chars().count() as isize - 1;
7807    }
7808}
7809
7810fn find_close_bracket(
7811    lines: &[String],
7812    row: usize,
7813    start_col: usize,
7814    open: char,
7815    close: char,
7816) -> Option<(usize, usize)> {
7817    let mut depth: i32 = 0;
7818    let mut r = row;
7819    let mut c = start_col;
7820    loop {
7821        let cur = &lines[r];
7822        let chars: Vec<char> = cur.chars().collect();
7823        while c < chars.len() {
7824            let ch = chars[c];
7825            if ch == open {
7826                depth += 1;
7827            } else if ch == close {
7828                if depth == 0 {
7829                    return Some((r, c));
7830                }
7831                depth -= 1;
7832            }
7833            c += 1;
7834        }
7835        if r + 1 >= lines.len() {
7836            return None;
7837        }
7838        r += 1;
7839        c = 0;
7840    }
7841}
7842
7843/// Forward scan from `(row, col)` for the next occurrence of `open`.
7844/// Multi-line. Used by bracket text objects to support targets.vim-style
7845/// "search forward when not currently inside a pair" behaviour.
7846fn find_next_open(lines: &[String], row: usize, col: usize, open: char) -> Option<(usize, usize)> {
7847    let mut r = row;
7848    let mut c = col;
7849    while r < lines.len() {
7850        let chars: Vec<char> = lines[r].chars().collect();
7851        while c < chars.len() {
7852            if chars[c] == open {
7853                return Some((r, c));
7854            }
7855            c += 1;
7856        }
7857        r += 1;
7858        c = 0;
7859    }
7860    None
7861}
7862
7863fn advance_pos(lines: &[String], pos: (usize, usize)) -> (usize, usize) {
7864    let (r, c) = pos;
7865    let line_len = lines[r].chars().count();
7866    if c < line_len {
7867        (r, c + 1)
7868    } else if r + 1 < lines.len() {
7869        (r + 1, 0)
7870    } else {
7871        pos
7872    }
7873}
7874
7875fn paragraph_text_object<H: crate::types::Host>(
7876    ed: &Editor<hjkl_buffer::Buffer, H>,
7877    inner: bool,
7878    count: usize,
7879) -> Option<((usize, usize), (usize, usize))> {
7880    let count = count.max(1);
7881    let (row, _) = ed.cursor();
7882    let rope = crate::types::Query::rope(&ed.buffer);
7883    let n_lines = rope.len_lines();
7884    if n_lines == 0 {
7885        return None;
7886    }
7887    // A paragraph is a run of non-blank lines.
7888    let is_blank = |r: usize| -> bool {
7889        if r >= n_lines {
7890            return true;
7891        }
7892        rope_line_to_str(&rope, r).trim().is_empty()
7893    };
7894    if is_blank(row) {
7895        return None;
7896    }
7897    let mut top = row;
7898    while top > 0 && !is_blank(top - 1) {
7899        top -= 1;
7900    }
7901    let mut bot = row;
7902    while bot + 1 < n_lines && !is_blank(bot + 1) {
7903        bot += 1;
7904    }
7905    // For `ap`, include one trailing blank line if present.
7906    if !inner && bot + 1 < n_lines && is_blank(bot + 1) {
7907        bot += 1;
7908    }
7909    // `Nip` / `Nap` extend across `count - 1` further units. For `ip` a unit is
7910    // a single block — a maximal run of same-blankness lines — so counting
7911    // alternates paragraph → blank gap → paragraph …. For `ap` a unit is a
7912    // whole paragraph together with its trailing blank gap (vim `:help ap`),
7913    // so `2ap` reaches the blank lines after the second paragraph too.
7914    let mut rem = count - 1;
7915    while rem > 0 && bot + 1 < n_lines {
7916        if inner {
7917            let blank_next = is_blank(bot + 1);
7918            bot += 1;
7919            while bot + 1 < n_lines && is_blank(bot + 1) == blank_next {
7920                bot += 1;
7921            }
7922        } else {
7923            while bot + 1 < n_lines && !is_blank(bot + 1) {
7924                bot += 1;
7925            }
7926            while bot + 1 < n_lines && is_blank(bot + 1) {
7927                bot += 1;
7928            }
7929        }
7930        rem -= 1;
7931    }
7932    let end_col = rope_line_to_str(&rope, bot).chars().count();
7933    Some(((top, 0), (bot, end_col)))
7934}
7935
7936// ─── Individual commands ───────────────────────────────────────────────────
7937
7938/// Read the text in a vim-shaped range without mutating. Used by
7939/// `Operator::Yank` so we can pipe the same range translation as
7940/// [`cut_vim_range`] but skip the delete + inverse extraction.
7941fn read_vim_range<H: crate::types::Host>(
7942    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7943    start: (usize, usize),
7944    end: (usize, usize),
7945    kind: RangeKind,
7946) -> String {
7947    let (top, bot) = order(start, end);
7948    ed.sync_buffer_content_from_textarea();
7949    let rope = crate::types::Query::rope(&ed.buffer);
7950    let n_lines = rope.len_lines();
7951    match kind {
7952        RangeKind::Linewise => {
7953            let lo = top.0;
7954            let hi = bot.0.min(n_lines.saturating_sub(1));
7955            let mut text = rope_row_range_str(&rope, lo, hi);
7956            text.push('\n');
7957            text
7958        }
7959        RangeKind::Inclusive | RangeKind::Exclusive => {
7960            let inclusive = matches!(kind, RangeKind::Inclusive);
7961            // Walk row-by-row collecting chars in `[top, end_exclusive)`.
7962            let mut out = String::new();
7963            for row in top.0..=bot.0 {
7964                if row >= n_lines {
7965                    break;
7966                }
7967                let line = rope_line_to_str(&rope, row);
7968                let lo = if row == top.0 { top.1 } else { 0 };
7969                let hi_unclamped = if row == bot.0 {
7970                    if inclusive { bot.1 + 1 } else { bot.1 }
7971                } else {
7972                    line.chars().count() + 1
7973                };
7974                let row_chars: Vec<char> = line.chars().collect();
7975                let hi = hi_unclamped.min(row_chars.len());
7976                if lo < hi {
7977                    out.push_str(&row_chars[lo..hi].iter().collect::<String>());
7978                }
7979                if row < bot.0 {
7980                    out.push('\n');
7981                }
7982            }
7983            out
7984        }
7985    }
7986}
7987
7988/// Cut a vim-shaped range through the Buffer edit funnel and return
7989/// the deleted text. Translates vim's `RangeKind`
7990/// (Linewise/Inclusive/Exclusive) into the buffer's
7991/// `hjkl_buffer::MotionKind` (Line/Char) and applies the right end-
7992/// position adjustment so inclusive motions actually include the bot
7993/// cell. Pushes the cut text into the clipboard via `record_yank_to_host`
7994/// and the textarea yank buffer (still observed by `p`/`P` until the paste
7995/// path is ported), and updates `yank_linewise` for linewise cuts.
7996fn cut_vim_range<H: crate::types::Host>(
7997    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7998    start: (usize, usize),
7999    end: (usize, usize),
8000    kind: RangeKind,
8001) -> String {
8002    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
8003    let (top, bot) = order(start, end);
8004    ed.sync_buffer_content_from_textarea();
8005    let (buf_start, buf_end, buf_kind) = match kind {
8006        RangeKind::Linewise => (
8007            Position::new(top.0, 0),
8008            Position::new(bot.0, 0),
8009            BufKind::Line,
8010        ),
8011        RangeKind::Inclusive => {
8012            let line_chars = buf_line_chars(&ed.buffer, bot.0);
8013            // Advance one cell past `bot` so the buffer's exclusive
8014            // `cut_chars` actually drops the inclusive endpoint. Wrap
8015            // to the next row when bot already sits on the last char.
8016            let next = if bot.1 < line_chars {
8017                Position::new(bot.0, bot.1 + 1)
8018            } else if bot.0 + 1 < buf_row_count(&ed.buffer) {
8019                Position::new(bot.0 + 1, 0)
8020            } else {
8021                Position::new(bot.0, line_chars)
8022            };
8023            (Position::new(top.0, top.1), next, BufKind::Char)
8024        }
8025        RangeKind::Exclusive => (
8026            Position::new(top.0, top.1),
8027            Position::new(bot.0, bot.1),
8028            BufKind::Char,
8029        ),
8030    };
8031    let inverse = ed.mutate_edit(Edit::DeleteRange {
8032        start: buf_start,
8033        end: buf_end,
8034        kind: buf_kind,
8035    });
8036    let text = match inverse {
8037        Edit::InsertStr { text, .. } => text,
8038        _ => String::new(),
8039    };
8040    if !text.is_empty() {
8041        ed.record_yank_to_host(text.clone());
8042        ed.record_delete(text.clone(), matches!(kind, RangeKind::Linewise));
8043    }
8044    ed.push_buffer_cursor_to_textarea();
8045    text
8046}
8047
8048/// `D` / `C` — delete from cursor to end of line through the edit
8049/// funnel. Pushes the deleted text to the clipboard via `record_yank_to_host`
8050/// and the textarea's yank buffer (still observed by `p`/`P` until the paste
8051/// path is ported). Cursor lands at the deletion start so the caller
8052/// can decide whether to step it left (`D`) or open insert mode (`C`).
8053fn delete_to_eol<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
8054    use hjkl_buffer::{Edit, MotionKind, Position};
8055    ed.sync_buffer_content_from_textarea();
8056    let cursor = buf_cursor_pos(&ed.buffer);
8057    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
8058    if cursor.col >= line_chars {
8059        return;
8060    }
8061    let inverse = ed.mutate_edit(Edit::DeleteRange {
8062        start: cursor,
8063        end: Position::new(cursor.row, line_chars),
8064        kind: MotionKind::Char,
8065    });
8066    if let Edit::InsertStr { text, .. } = inverse
8067        && !text.is_empty()
8068    {
8069        ed.record_yank_to_host(text.clone());
8070        ed.vim.yank_linewise = false;
8071        ed.set_yank(text);
8072    }
8073    buf_set_cursor_pos(&mut ed.buffer, cursor);
8074    ed.push_buffer_cursor_to_textarea();
8075}
8076
8077fn do_char_delete<H: crate::types::Host>(
8078    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8079    forward: bool,
8080    count: usize,
8081) {
8082    use hjkl_buffer::{Edit, MotionKind, Position};
8083    ed.push_undo();
8084    ed.sync_buffer_content_from_textarea();
8085    // Collect deleted chars so we can write them to the unnamed register
8086    // (vim's `x`/`X` populate `"` so that `xp` round-trips the char).
8087    let mut deleted = String::new();
8088    for _ in 0..count {
8089        let cursor = buf_cursor_pos(&ed.buffer);
8090        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
8091        if forward {
8092            // `x` — delete the char under the cursor. Vim no-ops on
8093            // an empty line; the buffer would drop a row otherwise.
8094            // `break`, not `continue`: the cursor can't regress below
8095            // EOL, so remaining iterations would spin uselessly (a
8096            // saturated count prefix would hang the editor).
8097            if cursor.col >= line_chars {
8098                break;
8099            }
8100            let inverse = ed.mutate_edit(Edit::DeleteRange {
8101                start: cursor,
8102                end: Position::new(cursor.row, cursor.col + 1),
8103                kind: MotionKind::Char,
8104            });
8105            if let Edit::InsertStr { text, .. } = inverse {
8106                deleted.push_str(&text);
8107            }
8108        } else {
8109            // `X` — delete the char before the cursor. `break` for the
8110            // same no-further-progress reason as the `x` arm above.
8111            if cursor.col == 0 {
8112                break;
8113            }
8114            let inverse = ed.mutate_edit(Edit::DeleteRange {
8115                start: Position::new(cursor.row, cursor.col - 1),
8116                end: cursor,
8117                kind: MotionKind::Char,
8118            });
8119            if let Edit::InsertStr { text, .. } = inverse {
8120                // X deletes backwards; prepend so the register text
8121                // matches reading order (first deleted char first).
8122                deleted = text + &deleted;
8123            }
8124        }
8125    }
8126    if !deleted.is_empty() {
8127        ed.record_yank_to_host(deleted.clone());
8128        ed.record_delete(deleted, false);
8129    }
8130    ed.push_buffer_cursor_to_textarea();
8131}
8132
8133/// Vim `Ctrl-a` / `Ctrl-x` — find the next number at or after the cursor on the
8134/// current line, add `delta`, leave the cursor on the last digit of the result.
8135/// Recognises `0x`/`0X` hex literals (incremented in hex, width preserved) as
8136/// well as signed decimals. No-op if the line has no number to the right.
8137pub(crate) fn adjust_number<H: crate::types::Host>(
8138    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8139    delta: i64,
8140) -> bool {
8141    use hjkl_buffer::{Edit, MotionKind, Position};
8142    ed.sync_buffer_content_from_textarea();
8143    let cursor = buf_cursor_pos(&ed.buffer);
8144    let row = cursor.row;
8145    let chars: Vec<char> = match buf_line(&ed.buffer, row) {
8146        Some(l) => l.chars().collect(),
8147        None => return false,
8148    };
8149    let len = chars.len();
8150
8151    // Scan from the cursor for the start of the leftmost number — a `0x`/`0X`
8152    // hex literal takes priority over a bare decimal at the same position.
8153    let is_hex_prefix = |i: usize| {
8154        chars[i] == '0'
8155            && i + 1 < len
8156            && matches!(chars[i + 1], 'x' | 'X')
8157            && chars.get(i + 2).is_some_and(|c| c.is_ascii_hexdigit())
8158    };
8159    let mut i = cursor.col;
8160    let mut hex = false;
8161    loop {
8162        if i >= len {
8163            return false;
8164        }
8165        if is_hex_prefix(i) {
8166            hex = true;
8167            break;
8168        }
8169        if chars[i].is_ascii_digit() {
8170            break;
8171        }
8172        i += 1;
8173    }
8174
8175    let (span_start, span_end, new_s) = if hex {
8176        // `0x` + hex digits. Increment the value, preserve the digit width.
8177        let digits_start = i + 2;
8178        let mut digits_end = digits_start;
8179        while digits_end < len && chars[digits_end].is_ascii_hexdigit() {
8180            digits_end += 1;
8181        }
8182        let hexs: String = chars[digits_start..digits_end].iter().collect();
8183        let Ok(n) = u64::from_str_radix(&hexs, 16) else {
8184            return false;
8185        };
8186        let new_val = (n as i128 + delta as i128).max(0) as u64;
8187        let width = digits_end - digits_start;
8188        let prefix: String = chars[i..digits_start].iter().collect();
8189        (i, digits_end, format!("{prefix}{new_val:0width$x}"))
8190    } else {
8191        // Signed decimal.
8192        let digit_start = i;
8193        let span_start = if digit_start > 0 && chars[digit_start - 1] == '-' {
8194            digit_start - 1
8195        } else {
8196            digit_start
8197        };
8198        let mut span_end = digit_start;
8199        while span_end < len && chars[span_end].is_ascii_digit() {
8200            span_end += 1;
8201        }
8202        let s: String = chars[span_start..span_end].iter().collect();
8203        let Ok(n) = s.parse::<i64>() else {
8204            return false;
8205        };
8206        (span_start, span_end, n.saturating_add(delta).to_string())
8207    };
8208
8209    ed.push_undo();
8210    let span_start_pos = Position::new(row, span_start);
8211    let span_end_pos = Position::new(row, span_end);
8212    ed.mutate_edit(Edit::DeleteRange {
8213        start: span_start_pos,
8214        end: span_end_pos,
8215        kind: MotionKind::Char,
8216    });
8217    ed.mutate_edit(Edit::InsertStr {
8218        at: span_start_pos,
8219        text: new_s.clone(),
8220    });
8221    let new_len = new_s.chars().count();
8222    buf_set_cursor_rc(&mut ed.buffer, row, span_start + new_len.saturating_sub(1));
8223    ed.push_buffer_cursor_to_textarea();
8224    true
8225}
8226
8227pub(crate) fn replace_char<H: crate::types::Host>(
8228    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8229    ch: char,
8230    count: usize,
8231) {
8232    use hjkl_buffer::{Edit, MotionKind, Position};
8233    ed.sync_buffer_content_from_textarea();
8234    // Vim aborts `r{count}{char}` entirely — replacing nothing — when fewer
8235    // than `count` characters remain from the cursor to end-of-line, rather
8236    // than replacing a partial run. Check before touching undo/the buffer.
8237    let start = buf_cursor_pos(&ed.buffer);
8238    let start_line_chars = buf_line_chars(&ed.buffer, start.row);
8239    if count == 0 || start.col + count > start_line_chars {
8240        return;
8241    }
8242    ed.push_undo();
8243    for _ in 0..count {
8244        let cursor = buf_cursor_pos(&ed.buffer);
8245        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
8246        if cursor.col >= line_chars {
8247            break;
8248        }
8249        ed.mutate_edit(Edit::DeleteRange {
8250            start: cursor,
8251            end: Position::new(cursor.row, cursor.col + 1),
8252            kind: MotionKind::Char,
8253        });
8254        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
8255    }
8256    // Vim leaves the cursor on the last replaced char.
8257    crate::motions::move_left(&mut ed.buffer, 1);
8258    ed.push_buffer_cursor_to_textarea();
8259}
8260
8261/// Returns `false` when there is no char under the cursor to toggle
8262/// (end of line / empty line) so counted loops can stop instead of
8263/// spinning through a saturated count prefix.
8264fn toggle_case_at_cursor<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) -> bool {
8265    use hjkl_buffer::{Edit, MotionKind, Position};
8266    ed.sync_buffer_content_from_textarea();
8267    let cursor = buf_cursor_pos(&ed.buffer);
8268    let Some(c) = buf_line(&ed.buffer, cursor.row).and_then(|l| l.chars().nth(cursor.col)) else {
8269        return false;
8270    };
8271    let toggled = if c.is_uppercase() {
8272        c.to_lowercase().next().unwrap_or(c)
8273    } else {
8274        c.to_uppercase().next().unwrap_or(c)
8275    };
8276    ed.mutate_edit(Edit::DeleteRange {
8277        start: cursor,
8278        end: Position::new(cursor.row, cursor.col + 1),
8279        kind: MotionKind::Char,
8280    });
8281    ed.mutate_edit(Edit::InsertChar {
8282        at: cursor,
8283        ch: toggled,
8284    });
8285    true
8286}
8287
8288/// Returns `false` when the cursor is on the last line (nothing to
8289/// join) so counted loops can stop instead of spinning.
8290fn join_line<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) -> bool {
8291    use hjkl_buffer::{Edit, Position};
8292    ed.sync_buffer_content_from_textarea();
8293    let row = buf_cursor_pos(&ed.buffer).row;
8294    if row + 1 >= buf_row_count(&ed.buffer) {
8295        return false;
8296    }
8297    let cur_line = buf_line(&ed.buffer, row).unwrap_or_default();
8298    let next_raw = buf_line(&ed.buffer, row + 1).unwrap_or_default();
8299    let next_trimmed = next_raw.trim_start();
8300    let cur_chars = cur_line.chars().count();
8301    let next_chars = next_raw.chars().count();
8302    // `J` inserts a single space iff both sides are non-empty after
8303    // stripping the next line's leading whitespace.
8304    let separator = if !cur_line.is_empty() && !next_trimmed.is_empty() {
8305        " "
8306    } else {
8307        ""
8308    };
8309    let joined = format!("{cur_line}{separator}{next_trimmed}");
8310    ed.mutate_edit(Edit::Replace {
8311        start: Position::new(row, 0),
8312        end: Position::new(row + 1, next_chars),
8313        with: joined,
8314    });
8315    // Vim parks the cursor on the inserted space — or at the join
8316    // point when no space went in (which is the same column either
8317    // way, since the space sits exactly at `cur_chars`).
8318    buf_set_cursor_rc(&mut ed.buffer, row, cur_chars);
8319    ed.push_buffer_cursor_to_textarea();
8320    true
8321}
8322
8323/// `gJ` — join the next line onto the current one without inserting a
8324/// separating space or stripping leading whitespace.
8325/// Returns `false` when the cursor is on the last line. See [`join_line`].
8326fn join_line_raw<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) -> bool {
8327    use hjkl_buffer::Edit;
8328    ed.sync_buffer_content_from_textarea();
8329    let row = buf_cursor_pos(&ed.buffer).row;
8330    if row + 1 >= buf_row_count(&ed.buffer) {
8331        return false;
8332    }
8333    let join_col = buf_line_chars(&ed.buffer, row);
8334    ed.mutate_edit(Edit::JoinLines {
8335        row,
8336        count: 1,
8337        with_space: false,
8338    });
8339    // Vim leaves the cursor at the join point (end of original line).
8340    buf_set_cursor_rc(&mut ed.buffer, row, join_col);
8341    ed.push_buffer_cursor_to_textarea();
8342    true
8343}
8344
8345/// Visual-mode `J` (`with_space = true`) / `gJ` (`with_space = false`) — join
8346/// every line spanned by the selection into one. A single-line selection joins
8347/// the current line with the one below (matching normal-mode `J`).
8348pub(crate) fn visual_join<H: crate::types::Host>(
8349    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8350    with_space: bool,
8351) {
8352    let cursor_row = buf_cursor_pos(&ed.buffer).row;
8353    let (top, bot) = match ed.vim.mode {
8354        Mode::VisualLine => (
8355            cursor_row.min(ed.vim.visual_line_anchor),
8356            cursor_row.max(ed.vim.visual_line_anchor),
8357        ),
8358        Mode::VisualBlock => {
8359            let a = ed.vim.block_anchor.0;
8360            (a.min(cursor_row), a.max(cursor_row))
8361        }
8362        Mode::Visual => {
8363            let a = ed.vim.visual_anchor.0;
8364            (a.min(cursor_row), a.max(cursor_row))
8365        }
8366        _ => return,
8367    };
8368    // N selected lines → N-1 joins; a single line still does one join (with the
8369    // line below) like normal-mode `J`.
8370    let joins = (bot - top).max(1);
8371    ed.push_undo();
8372    buf_set_cursor_rc(&mut ed.buffer, top, 0);
8373    ed.push_buffer_cursor_to_textarea();
8374    for _ in 0..joins {
8375        let joined = if with_space {
8376            join_line(ed)
8377        } else {
8378            join_line_raw(ed)
8379        };
8380        if !joined {
8381            break;
8382        }
8383    }
8384    ed.vim.mode = Mode::Normal;
8385    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
8386}
8387
8388/// `[count]%` — go to the line at `count` percent of the file (vim: line
8389/// `(count * line_count + 99) / 100`), cursor on the first non-blank.
8390pub(crate) fn goto_percent<H: crate::types::Host>(
8391    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8392    count: usize,
8393) {
8394    let rows = buf_row_count(&ed.buffer);
8395    if rows == 0 {
8396        return;
8397    }
8398    // Exclude the phantom trailing empty line (a file ending in `\n` is N lines
8399    // in vim, not N+1) so the percentage matches nvim.
8400    let total = if rows >= 2
8401        && buf_line(&ed.buffer, rows - 1)
8402            .map(|s| s.is_empty())
8403            .unwrap_or(false)
8404    {
8405        rows - 1
8406    } else {
8407        rows
8408    };
8409    // 1-based target line, clamped to the buffer (vim: ceil(count*lines/100)).
8410    // Saturating: a pathological count prefix (e.g. 20 typed digits) must not
8411    // overflow the multiply; the clamp below caps the result at `total` anyway.
8412    let line = count.saturating_mul(total).div_ceil(100).clamp(1, total);
8413    let pre = ed.cursor();
8414    ed.jump_cursor(line - 1, 0);
8415    move_first_non_whitespace(ed);
8416    ed.sticky_col = Some(ed.cursor().1);
8417    if ed.cursor() != pre {
8418        ed.push_jump(pre);
8419    }
8420}
8421
8422/// Indent width of a leading-whitespace prefix, counting a `\t` as advancing
8423/// to the next `tabstop` boundary and a space as one column.
8424fn indent_width(s: &str, tabstop: usize) -> usize {
8425    let ts = tabstop.max(1);
8426    let mut w = 0usize;
8427    for c in s.chars() {
8428        match c {
8429            ' ' => w += 1,
8430            '\t' => w += ts - (w % ts),
8431            _ => break,
8432        }
8433    }
8434    w
8435}
8436
8437/// Build a leading-whitespace string of `width` columns honoring `expandtab`
8438/// (spaces) vs `noexpandtab` (tabs for full `tabstop` runs, spaces remainder).
8439fn build_indent(width: usize, settings: &crate::editor::Settings) -> String {
8440    if settings.expandtab {
8441        return " ".repeat(width);
8442    }
8443    let ts = settings.tabstop.max(1);
8444    let tabs = width / ts;
8445    let spaces = width % ts;
8446    format!("{}{}", "\t".repeat(tabs), " ".repeat(spaces))
8447}
8448
8449/// `]p` / `[p` reindent: shift every line of `text` so the FIRST line's indent
8450/// matches `target_width` columns; later lines keep their relative offset.
8451fn reindent_block(text: &str, target_width: usize, settings: &crate::editor::Settings) -> String {
8452    let ts = settings.tabstop.max(1);
8453    let lines: Vec<&str> = text.split('\n').collect();
8454    let first_width = lines.first().map(|l| indent_width(l, ts)).unwrap_or(0);
8455    let delta = target_width as isize - first_width as isize;
8456    lines
8457        .iter()
8458        .map(|line| {
8459            let trimmed = line.trim_start_matches([' ', '\t']);
8460            if trimmed.is_empty() {
8461                // Preserve blank lines as truly empty (vim does not indent them).
8462                return String::new();
8463            }
8464            let old_w = indent_width(line, ts) as isize;
8465            let new_w = (old_w + delta).max(0) as usize;
8466            format!("{}{}", build_indent(new_w, settings), trimmed)
8467        })
8468        .collect::<Vec<_>>()
8469        .join("\n")
8470}
8471
8472fn do_paste<H: crate::types::Host>(
8473    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8474    before: bool,
8475    count: usize,
8476    cursor_after: bool,
8477    reindent: bool,
8478) {
8479    use hjkl_buffer::{Edit, Position};
8480    ed.push_undo();
8481    // Resolve the source register: `"reg` prefix (consumed) or the
8482    // unnamed register otherwise. Read text + linewise from the
8483    // selected slot rather than the global `vim.yank_linewise` so
8484    // pasting from `"0` after a delete still uses the yank's layout.
8485    let selector = ed.vim.pending_register.take();
8486    let (yank, linewise) = {
8487        let regs = ed.registers();
8488        match selector.and_then(|c| regs.read(c)) {
8489            Some(slot) => (slot.text.clone(), slot.linewise),
8490            // Read both fields from the unnamed slot rather than mixing the
8491            // slot's text with `vim.yank_linewise`. The cached vim flag is
8492            // per-editor, so a register imported from another editor (e.g.
8493            // cross-buffer yank/paste) carried the wrong linewise without
8494            // this — pasting a linewise yank inserted at the char cursor.
8495            None => (regs.unnamed.text.clone(), regs.unnamed.linewise),
8496        }
8497    };
8498    // Vim `:h '[` / `:h ']`: after paste `[` = first inserted char of
8499    // the final paste, `]` = last inserted char of the final paste.
8500    // We track (lo, hi) across iterations; the last value wins.
8501    let mut paste_mark: Option<((usize, usize), (usize, usize))> = None;
8502    // Capture the cursor row before any paste iterations. Vim's
8503    // linewise `[count]p` lands the cursor on the FIRST pasted line
8504    // (original_row + 1), not on the last iteration's paste row.
8505    // Without this snapshot the per-iteration cursor advancement leaves
8506    // the cursor at `original_row + count` instead.
8507    let original_row_for_linewise_after = if linewise && !before {
8508        // Fold-aware: `p` on a closed fold pastes after the fold, so the first
8509        // pasted line is `fold_end + 1`, not `cursor_row + 1`.
8510        let r = buf_cursor_pos(&ed.buffer).row;
8511        let (_, fold_end) = expand_linewise_over_closed_folds(&ed.buffer, r, r);
8512        Some(fold_end)
8513    } else {
8514        None
8515    };
8516    // Empty register: nothing to paste on any iteration — bail before the
8517    // loop instead of `continue`-spinning through a huge count prefix.
8518    if yank.is_empty() {
8519        return;
8520    }
8521    for _ in 0..count {
8522        ed.sync_buffer_content_from_textarea();
8523        let yank = yank.clone();
8524        if linewise {
8525            // Linewise paste: insert payload as fresh row(s) above
8526            // (`P`) or below (`p`) the cursor's row. Cursor lands on
8527            // the first non-blank of the first pasted line.
8528            let mut text = yank.trim_matches('\n').to_string();
8529            let row = buf_cursor_pos(&ed.buffer).row;
8530            // `]p` / `[p` — reindent the pasted block to the current line.
8531            if reindent {
8532                let cur_line = buf_line(&ed.buffer, row).unwrap_or_default();
8533                let target_w = indent_width(&cur_line, ed.settings.tabstop.max(1));
8534                text = reindent_block(&text, target_w, &ed.settings);
8535            }
8536            // Fold-aware: linewise paste lands relative to the whole CLOSED
8537            // fold, not just the cursor line — `p` after the fold's last row,
8538            // `P` before its first row (vim behaviour). No fold → unchanged.
8539            let (fold_start, fold_end) = expand_linewise_over_closed_folds(&ed.buffer, row, row);
8540            let target_row = if before {
8541                ed.mutate_edit(Edit::InsertStr {
8542                    at: Position::new(fold_start, 0),
8543                    text: format!("{text}\n"),
8544                });
8545                fold_start
8546            } else {
8547                let line_chars = buf_line_chars(&ed.buffer, fold_end);
8548                ed.mutate_edit(Edit::InsertStr {
8549                    at: Position::new(fold_end, line_chars),
8550                    text: format!("\n{text}"),
8551                });
8552                fold_end + 1
8553            };
8554            buf_set_cursor_rc(&mut ed.buffer, target_row, 0);
8555            crate::motions::move_first_non_blank(&mut ed.buffer);
8556            ed.push_buffer_cursor_to_textarea();
8557            // Linewise: `[` = (target_row, 0), `]` = (bot_row, last_col).
8558            let payload_lines = text.lines().count().max(1);
8559            let bot_row = target_row + payload_lines - 1;
8560            let bot_last_col = buf_line_chars(&ed.buffer, bot_row).saturating_sub(1);
8561            paste_mark = Some(((target_row, 0), (bot_row, bot_last_col)));
8562        } else {
8563            // Charwise paste. `P` inserts at cursor (shifting cell
8564            // right); `p` inserts after cursor (advance one cell
8565            // first, clamped to the end of the line).
8566            let cursor = buf_cursor_pos(&ed.buffer);
8567            let at = if before {
8568                cursor
8569            } else {
8570                let line_chars = buf_line_chars(&ed.buffer, cursor.row);
8571                Position::new(cursor.row, (cursor.col + 1).min(line_chars))
8572            };
8573            ed.mutate_edit(Edit::InsertStr {
8574                at,
8575                text: yank.clone(),
8576            });
8577            // Vim parks the cursor on the last char of the pasted text
8578            // (do_insert_str leaves it one past the end). `gp` instead
8579            // leaves the cursor just AFTER the pasted text, so skip the
8580            // step-back there.
8581            if !cursor_after && ed.cursor().1 > 0 {
8582                crate::motions::move_left(&mut ed.buffer, 1);
8583                ed.push_buffer_cursor_to_textarea();
8584            }
8585            // Charwise: `[` = insert start, `]` = last pasted char.
8586            let lo = (at.row, at.col);
8587            let hi = if cursor_after {
8588                let c = ed.cursor();
8589                (c.0, c.1.saturating_sub(1))
8590            } else {
8591                ed.cursor()
8592            };
8593            paste_mark = Some((lo, hi));
8594        }
8595    }
8596    if let Some((lo, hi)) = paste_mark {
8597        ed.set_mark('[', lo);
8598        ed.set_mark(']', hi);
8599    }
8600    // `gp` / `gP` linewise: cursor lands on the line just AFTER the pasted
8601    // block (the `]` mark's row + 1), at column 0, clamped to the last row.
8602    if cursor_after && linewise {
8603        if let Some((_, (bot_row, _))) = paste_mark {
8604            let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
8605            let target = (bot_row + 1).min(last_row);
8606            buf_set_cursor_rc(&mut ed.buffer, target, 0);
8607            ed.push_buffer_cursor_to_textarea();
8608        }
8609    } else if let Some(orig_row) = original_row_for_linewise_after {
8610        // Linewise `p` (after) with count: cursor lands on the FIRST pasted
8611        // line (original_row + 1) — vim parity. The per-iteration loop
8612        // moves cursor to each paste's target_row, so without this reset
8613        // `5p` would land at original_row + 5 instead of original_row + 1.
8614        let first_target = orig_row.saturating_add(1);
8615        buf_set_cursor_rc(&mut ed.buffer, first_target, 0);
8616        crate::motions::move_first_non_blank(&mut ed.buffer);
8617        ed.push_buffer_cursor_to_textarea();
8618    }
8619    // Any paste re-anchors the sticky column to the new cursor position.
8620    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
8621}
8622
8623/// Visual-mode `p` / `P` — replace the active selection with the register.
8624/// With `p` the deleted selection lands in the unnamed register (vim's swap);
8625/// with `P` (`before = true`) the source register is preserved so it can be
8626/// pasted over multiple selections in turn.
8627pub(crate) fn visual_paste<H: crate::types::Host>(
8628    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8629    before: bool,
8630) {
8631    use hjkl_buffer::{Edit, Position};
8632    ed.sync_buffer_content_from_textarea();
8633
8634    // Resolve the source register (selector or unnamed) BEFORE the delete
8635    // overwrites the unnamed register with the cut selection.
8636    let selector = ed.vim.pending_register.take();
8637    let (reg_text, reg_linewise) = {
8638        let regs = ed.registers();
8639        match selector.and_then(|c| regs.read(c)) {
8640            Some(slot) => (slot.text.clone(), slot.linewise),
8641            None => (regs.unnamed.text.clone(), regs.unnamed.linewise),
8642        }
8643    };
8644    // For `P`, snapshot the unnamed register so we can restore it afterwards.
8645    let saved_unnamed = before.then(|| ed.registers().unnamed.clone());
8646
8647    let mode = ed.vim.mode;
8648    ed.push_undo();
8649
8650    match mode {
8651        Mode::VisualLine => {
8652            let cursor_row = buf_cursor_pos(&ed.buffer).row;
8653            let top = cursor_row.min(ed.vim.visual_line_anchor);
8654            let bot = cursor_row.max(ed.vim.visual_line_anchor);
8655            // Delete the selected lines into the unnamed register.
8656            cut_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
8657            // Insert the register as fresh line(s) where the selection was.
8658            let text = reg_text.trim_matches('\n').to_string();
8659            let line_count = buf_row_count(&ed.buffer);
8660            if top >= line_count {
8661                // Selection reached the end of the buffer: append below the
8662                // (new) last line.
8663                let last = line_count.saturating_sub(1);
8664                let lc = buf_line_chars(&ed.buffer, last);
8665                ed.mutate_edit(Edit::InsertStr {
8666                    at: Position::new(last, lc),
8667                    text: format!("\n{text}"),
8668                });
8669                buf_set_cursor_rc(&mut ed.buffer, last + 1, 0);
8670            } else {
8671                ed.mutate_edit(Edit::InsertStr {
8672                    at: Position::new(top, 0),
8673                    text: format!("{text}\n"),
8674                });
8675                buf_set_cursor_rc(&mut ed.buffer, top, 0);
8676            }
8677            crate::motions::move_first_non_blank(&mut ed.buffer);
8678            ed.push_buffer_cursor_to_textarea();
8679        }
8680        Mode::Visual | Mode::VisualBlock => {
8681            let anchor = if mode == Mode::VisualBlock {
8682                ed.vim.block_anchor
8683            } else {
8684                ed.vim.visual_anchor
8685            };
8686            let cursor = ed.cursor();
8687            let (top, bot) = order(anchor, cursor);
8688            // Delete the selection into the unnamed register.
8689            cut_vim_range(ed, top, bot, RangeKind::Inclusive);
8690            // Insert the register text where the selection started.
8691            if reg_linewise {
8692                // Linewise register into a charwise hole: open a line below.
8693                let text = reg_text.trim_matches('\n').to_string();
8694                let lc = buf_line_chars(&ed.buffer, top.0);
8695                ed.mutate_edit(Edit::InsertStr {
8696                    at: Position::new(top.0, lc),
8697                    text: format!("\n{text}"),
8698                });
8699                buf_set_cursor_rc(&mut ed.buffer, top.0 + 1, 0);
8700                crate::motions::move_first_non_blank(&mut ed.buffer);
8701            } else {
8702                ed.mutate_edit(Edit::InsertStr {
8703                    at: Position::new(top.0, top.1),
8704                    text: reg_text.clone(),
8705                });
8706                // Park the cursor on the last char of the inserted text.
8707                let inserted_len = reg_text.chars().count();
8708                let last_col = top.1 + inserted_len.saturating_sub(1);
8709                buf_set_cursor_rc(&mut ed.buffer, top.0, last_col);
8710            }
8711            ed.push_buffer_cursor_to_textarea();
8712        }
8713        _ => {}
8714    }
8715
8716    // `P` preserves the source register; restore the snapshot.
8717    if let Some(slot) = saved_unnamed {
8718        ed.registers_mut().unnamed = slot;
8719    }
8720    ed.vim.mode = Mode::Normal;
8721    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
8722}
8723
8724/// Visual-mode `<C-a>` / `<C-x>` and `g<C-a>` / `g<C-x>`. Adds `delta` to the
8725/// first number on each selected line. When `sequential` is true the increment
8726/// grows by `delta` for each successive number found (vim's `g<C-a>`): the
8727/// first gets `delta`, the second `2*delta`, and so on.
8728pub(crate) fn adjust_number_visual<H: crate::types::Host>(
8729    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8730    delta: i64,
8731    sequential: bool,
8732) {
8733    use hjkl_buffer::{Edit, MotionKind, Position};
8734    ed.sync_buffer_content_from_textarea();
8735    let mode = ed.vim.mode;
8736    let cursor = buf_cursor_pos(&ed.buffer);
8737
8738    // Resolve the row range + the per-row start column to scan from.
8739    let (top, bot, mut scan_col_first, block_left) = match mode {
8740        Mode::VisualLine => {
8741            let t = cursor.row.min(ed.vim.visual_line_anchor);
8742            let b = cursor.row.max(ed.vim.visual_line_anchor);
8743            (t, b, 0usize, None)
8744        }
8745        Mode::Visual => {
8746            let (a, c) = order(ed.vim.visual_anchor, (cursor.row, cursor.col));
8747            (a.0, c.0, a.1, None)
8748        }
8749        Mode::VisualBlock => {
8750            let (a, c) = order(ed.vim.block_anchor, (cursor.row, cursor.col));
8751            let left = a.1.min(c.1);
8752            (a.0, c.0, left, Some(left))
8753        }
8754        _ => return,
8755    };
8756
8757    ed.push_undo();
8758    let mut found_count: i64 = 0;
8759    for row in top..=bot {
8760        let start_col = match block_left {
8761            Some(left) => left,
8762            None => {
8763                // First row of a charwise selection starts at the anchor/cursor
8764                // column; subsequent rows start at column 0.
8765                let c = if row == top { scan_col_first } else { 0 };
8766                scan_col_first = 0;
8767                c
8768            }
8769        };
8770        let chars: Vec<char> = match buf_line(&ed.buffer, row) {
8771            Some(l) => l.chars().collect(),
8772            None => continue,
8773        };
8774        let Some(digit_start) =
8775            (start_col.min(chars.len())..chars.len()).find(|&i| chars[i].is_ascii_digit())
8776        else {
8777            continue;
8778        };
8779        let span_start = if digit_start > 0 && chars[digit_start - 1] == '-' {
8780            digit_start - 1
8781        } else {
8782            digit_start
8783        };
8784        let mut span_end = digit_start;
8785        while span_end < chars.len() && chars[span_end].is_ascii_digit() {
8786            span_end += 1;
8787        }
8788        let s: String = chars[span_start..span_end].iter().collect();
8789        let Ok(n) = s.parse::<i64>() else {
8790            continue;
8791        };
8792        found_count += 1;
8793        let this_delta = if sequential {
8794            delta.saturating_mul(found_count)
8795        } else {
8796            delta
8797        };
8798        let new_s = n.saturating_add(this_delta).to_string();
8799        let span_start_pos = Position::new(row, span_start);
8800        let span_end_pos = Position::new(row, span_end);
8801        ed.mutate_edit(Edit::DeleteRange {
8802            start: span_start_pos,
8803            end: span_end_pos,
8804            kind: MotionKind::Char,
8805        });
8806        ed.mutate_edit(Edit::InsertStr {
8807            at: span_start_pos,
8808            text: new_s,
8809        });
8810    }
8811    // Vim leaves the cursor at the start of the selection.
8812    buf_set_cursor_rc(&mut ed.buffer, top, block_left.unwrap_or(0));
8813    ed.push_buffer_cursor_to_textarea();
8814    ed.vim.mode = Mode::Normal;
8815    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
8816}
8817
8818pub(crate) fn do_undo<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
8819    if let Some(entry) = ed.buffer.pop_undo_entry() {
8820        let (cur_rope, cur_cursor) = ed.snapshot();
8821        ed.buffer.push_redo_entry(hjkl_buffer::UndoEntry {
8822            rope: cur_rope,
8823            cursor: cur_cursor,
8824            timestamp: entry.timestamp,
8825        });
8826        ed.restore_rope(entry.rope, entry.cursor);
8827    }
8828    ed.vim.mode = Mode::Normal;
8829    // The restored cursor came from a snapshot taken in insert mode
8830    // (before the insert started) and may be past the last valid
8831    // normal-mode column. Clamp it now, same as Esc-from-insert does.
8832    clamp_cursor_to_normal_mode(ed);
8833}
8834
8835pub(crate) fn do_redo<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
8836    if let Some(entry) = ed.buffer.pop_redo_entry() {
8837        let (cur_rope, cur_cursor) = ed.snapshot();
8838        let before = cur_rope.clone();
8839        ed.buffer.push_undo_entry(hjkl_buffer::UndoEntry {
8840            rope: cur_rope,
8841            cursor: cur_cursor,
8842            timestamp: entry.timestamp,
8843        });
8844        ed.cap_undo();
8845        ed.restore_rope(entry.rope, entry.cursor);
8846        // vim parks the cursor at the START of the reapplied change, not the
8847        // end-of-insert position stored in the redo snapshot. Recompute it from
8848        // the first character that differs between the pre- and post-redo text.
8849        let after = crate::types::Query::rope(&ed.buffer);
8850        if let Some((row, col)) = first_diff_pos(&before, &after) {
8851            buf_set_cursor_rc(&mut ed.buffer, row, col);
8852            ed.push_buffer_cursor_to_textarea();
8853        }
8854    }
8855    ed.vim.mode = Mode::Normal;
8856    clamp_cursor_to_normal_mode(ed);
8857}
8858
8859/// First `(row, col)` where two ropes differ, or `None` if identical. Used to
8860/// place the cursor at the start of a redone change (vim parity).
8861fn first_diff_pos(a: &ropey::Rope, b: &ropey::Rope) -> Option<(usize, usize)> {
8862    let rows = a.len_lines().max(b.len_lines());
8863    for r in 0..rows {
8864        let la = if r < a.len_lines() {
8865            hjkl_buffer::rope_line_str(a, r)
8866        } else {
8867            String::new()
8868        };
8869        let lb = if r < b.len_lines() {
8870            hjkl_buffer::rope_line_str(b, r)
8871        } else {
8872            String::new()
8873        };
8874        if la != lb {
8875            let col = la
8876                .chars()
8877                .zip(lb.chars())
8878                .take_while(|(x, y)| x == y)
8879                .count();
8880            return Some((r, col));
8881        }
8882    }
8883    None
8884}
8885
8886// ─── Dot repeat ────────────────────────────────────────────────────────────
8887
8888/// Replay-side helper: insert `text` at the cursor through the
8889/// edit funnel, then leave insert mode (the original change ended
8890/// with Esc, so the dot-repeat must end the same way — including
8891/// the cursor step-back vim does on Esc-from-insert).
8892fn replay_insert_and_finish<H: crate::types::Host>(
8893    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8894    text: &str,
8895) {
8896    use hjkl_buffer::{Edit, Position};
8897    let cursor = ed.cursor();
8898    ed.mutate_edit(Edit::InsertStr {
8899        at: Position::new(cursor.0, cursor.1),
8900        text: text.to_string(),
8901    });
8902    if ed.vim.insert_session.take().is_some() {
8903        if ed.cursor().1 > 0 {
8904            crate::motions::move_left(&mut ed.buffer, 1);
8905            ed.push_buffer_cursor_to_textarea();
8906        }
8907        ed.vim.mode = Mode::Normal;
8908    }
8909}
8910
8911pub(crate) fn replay_last_change<H: crate::types::Host>(
8912    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8913    outer_count: usize,
8914) {
8915    let Some(change) = ed.vim.last_change.clone() else {
8916        return;
8917    };
8918    ed.vim.replaying = true;
8919    // Dot-repeat with an explicit `[count].` *replaces* the change's stored
8920    // count (`:h .`): `3x` then `2.` deletes 2, not 6. `outer_count == 0`
8921    // means the user typed no count, so the original stored count is reused.
8922    // Both counts are individually capped; re-clamp at vim's ceiling
8923    // (`:h count`) so replay loops stay bounded.
8924    let explicit = if outer_count > 0 {
8925        Some(outer_count.min(MAX_COUNT))
8926    } else {
8927        None
8928    };
8929    let scaled = |c: usize| explicit.unwrap_or(c).min(MAX_COUNT);
8930    match change {
8931        LastChange::OpMotion {
8932            op,
8933            motion,
8934            count,
8935            inserted,
8936        } => {
8937            let total = scaled(count.max(1));
8938            apply_op_with_motion(ed, op, &motion, total);
8939            if let Some(text) = inserted {
8940                replay_insert_and_finish(ed, &text);
8941            }
8942        }
8943        LastChange::OpTextObj {
8944            op,
8945            obj,
8946            inner,
8947            inserted,
8948        } => {
8949            // Dot-repeat replays the text object at count 1 (the original
8950            // count is not retained in `LastChange::OpTextObj`).
8951            apply_op_with_text_object(ed, op, obj, inner, 1);
8952            if let Some(text) = inserted {
8953                replay_insert_and_finish(ed, &text);
8954            }
8955        }
8956        LastChange::LineOp {
8957            op,
8958            count,
8959            inserted,
8960        } => {
8961            let total = scaled(count.max(1));
8962            execute_line_op(ed, op, total);
8963            if let Some(text) = inserted {
8964                replay_insert_and_finish(ed, &text);
8965            }
8966        }
8967        LastChange::CharDel { forward, count } => {
8968            do_char_delete(ed, forward, scaled(count));
8969        }
8970        LastChange::ReplaceChar { ch, count } => {
8971            replace_char(ed, ch, scaled(count));
8972        }
8973        LastChange::ToggleCase { count } => {
8974            for _ in 0..scaled(count) {
8975                ed.push_undo();
8976                if !toggle_case_at_cursor(ed) {
8977                    break;
8978                }
8979            }
8980        }
8981        LastChange::JoinLine { count } => {
8982            for _ in 0..scaled(count) {
8983                ed.push_undo();
8984                if !join_line(ed) {
8985                    break;
8986                }
8987            }
8988        }
8989        LastChange::Paste {
8990            before,
8991            count,
8992            cursor_after,
8993            reindent,
8994        } => {
8995            do_paste(ed, before, scaled(count), cursor_after, reindent);
8996        }
8997        LastChange::GnOp {
8998            op,
8999            forward,
9000            inserted,
9001        } => {
9002            gn_operate(ed, Some(op), forward, 1);
9003            if let Some(text) = inserted {
9004                replay_insert_and_finish(ed, &text);
9005            }
9006        }
9007        LastChange::ReplaceMode { text } => {
9008            use hjkl_buffer::{Edit, MotionKind, Position};
9009            ed.push_undo();
9010            for ch in text.chars() {
9011                let cursor = buf_cursor_pos(&ed.buffer);
9012                let line_chars = buf_line_chars(&ed.buffer, cursor.row);
9013                if cursor.col < line_chars {
9014                    // Overtype the char under the cursor.
9015                    ed.mutate_edit(Edit::DeleteRange {
9016                        start: cursor,
9017                        end: Position::new(cursor.row, cursor.col + 1),
9018                        kind: MotionKind::Char,
9019                    });
9020                }
9021                ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
9022                buf_set_cursor_rc(&mut ed.buffer, cursor.row, cursor.col + 1);
9023            }
9024            // Esc step-back onto the last overtyped char.
9025            if ed.cursor().1 > 0 {
9026                crate::motions::move_left(&mut ed.buffer, 1);
9027            }
9028            ed.push_buffer_cursor_to_textarea();
9029        }
9030        LastChange::DeleteToEol { inserted } => {
9031            use hjkl_buffer::{Edit, Position};
9032            ed.push_undo();
9033            delete_to_eol(ed);
9034            if let Some(text) = inserted {
9035                let cursor = ed.cursor();
9036                ed.mutate_edit(Edit::InsertStr {
9037                    at: Position::new(cursor.0, cursor.1),
9038                    text,
9039                });
9040            }
9041        }
9042        LastChange::OpenLine { above, inserted } => {
9043            use hjkl_buffer::{Edit, Position};
9044            ed.push_undo();
9045            ed.sync_buffer_content_from_textarea();
9046            let row = buf_cursor_pos(&ed.buffer).row;
9047            if above {
9048                ed.mutate_edit(Edit::InsertStr {
9049                    at: Position::new(row, 0),
9050                    text: "\n".to_string(),
9051                });
9052                let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
9053                crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
9054            } else {
9055                let line_chars = buf_line_chars(&ed.buffer, row);
9056                ed.mutate_edit(Edit::InsertStr {
9057                    at: Position::new(row, line_chars),
9058                    text: "\n".to_string(),
9059                });
9060            }
9061            ed.push_buffer_cursor_to_textarea();
9062            let cursor = ed.cursor();
9063            ed.mutate_edit(Edit::InsertStr {
9064                at: Position::new(cursor.0, cursor.1),
9065                text: inserted,
9066            });
9067        }
9068        LastChange::InsertAt {
9069            entry,
9070            inserted,
9071            count,
9072        } => {
9073            use hjkl_buffer::{Edit, Position};
9074            ed.push_undo();
9075            match entry {
9076                InsertEntry::I => {}
9077                InsertEntry::ShiftI => move_first_non_whitespace(ed),
9078                InsertEntry::A => {
9079                    crate::motions::move_right_to_end(&mut ed.buffer, 1);
9080                    ed.push_buffer_cursor_to_textarea();
9081                }
9082                InsertEntry::ShiftA => {
9083                    crate::motions::move_line_end(&mut ed.buffer);
9084                    crate::motions::move_right_to_end(&mut ed.buffer, 1);
9085                    ed.push_buffer_cursor_to_textarea();
9086                }
9087            }
9088            for _ in 0..count.max(1) {
9089                let cursor = ed.cursor();
9090                ed.mutate_edit(Edit::InsertStr {
9091                    at: Position::new(cursor.0, cursor.1),
9092                    text: inserted.clone(),
9093                });
9094            }
9095        }
9096    }
9097    ed.vim.replaying = false;
9098}
9099
9100// ─── Extracting inserted text for replay ───────────────────────────────────
9101
9102/// The substring of `after` that differs from `before` (first-diff to
9103/// last-diff). Unlike [`extract_inserted`] this works for equal-length or
9104/// shorter results, so it captures `R` overstrike text for dot-repeat.
9105fn changed_run(before: &str, after: &str) -> String {
9106    let a: Vec<char> = before.chars().collect();
9107    let b: Vec<char> = after.chars().collect();
9108    let prefix = a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count();
9109    let max_suffix = a.len().min(b.len()) - prefix;
9110    let suffix = a
9111        .iter()
9112        .rev()
9113        .zip(b.iter().rev())
9114        .take(max_suffix)
9115        .take_while(|(x, y)| x == y)
9116        .count();
9117    b[prefix..b.len() - suffix].iter().collect()
9118}
9119
9120fn extract_inserted(before: &str, after: &str) -> String {
9121    let before_chars: Vec<char> = before.chars().collect();
9122    let after_chars: Vec<char> = after.chars().collect();
9123    if after_chars.len() <= before_chars.len() {
9124        return String::new();
9125    }
9126    let prefix = before_chars
9127        .iter()
9128        .zip(after_chars.iter())
9129        .take_while(|(a, b)| a == b)
9130        .count();
9131    let max_suffix = before_chars.len() - prefix;
9132    let suffix = before_chars
9133        .iter()
9134        .rev()
9135        .zip(after_chars.iter().rev())
9136        .take(max_suffix)
9137        .take_while(|(a, b)| a == b)
9138        .count();
9139    after_chars[prefix..after_chars.len() - suffix]
9140        .iter()
9141        .collect()
9142}
9143
9144// ─── Tests ────────────────────────────────────────────────────────────────
9145
9146#[cfg(test)]
9147mod replace_char_tests {
9148    use crate::{DefaultHost, Editor, Options};
9149    use hjkl_buffer::{Buffer, rope_line_str};
9150
9151    fn line(ed: &Editor<Buffer, DefaultHost>, row: usize) -> String {
9152        rope_line_str(&ed.buffer().rope(), row)
9153    }
9154
9155    #[test]
9156    fn replace_char_count_exceeding_line_replaces_nothing() {
9157        let buf = Buffer::from_str("ab\ncd");
9158        let mut ed = Editor::new(buf, DefaultHost::new(), Options::default());
9159        // Cursor at (0,0); `3rx` needs 3 chars but the line has 2 — vim aborts
9160        // the whole command and replaces nothing (not a partial run).
9161        ed.replace_char_at('x', 3);
9162        assert_eq!(line(&ed, 0), "ab", "partial replace must not happen");
9163        assert_eq!(line(&ed, 1), "cd", "must not spill onto the next line");
9164    }
9165
9166    #[test]
9167    fn replace_char_count_fitting_replaces_run() {
9168        let buf = Buffer::from_str("abc");
9169        let mut ed = Editor::new(buf, DefaultHost::new(), Options::default());
9170        ed.replace_char_at('x', 2);
9171        assert_eq!(line(&ed, 0), "xxc");
9172    }
9173}
9174
9175#[cfg(test)]
9176mod comment_continuation_tests {
9177    use super::*;
9178    use crate::{DefaultHost, Editor, Options};
9179    use hjkl_buffer::Buffer;
9180
9181    fn make_editor_with_lang(lang: &str, content: &str) -> Editor<Buffer, DefaultHost> {
9182        let buf = Buffer::from_str(content);
9183        let host = DefaultHost::new();
9184        let opts = Options {
9185            filetype: lang.to_string(),
9186            formatoptions: "ro".to_string(),
9187            ..Options::default()
9188        };
9189        Editor::new(buf, host, opts)
9190    }
9191
9192    #[test]
9193    fn detect_rust_doc_comment() {
9194        let result = detect_comment_on_line("rust", "/// foo bar");
9195        assert!(result.is_some());
9196        let (indent, prefix) = result.unwrap();
9197        assert_eq!(indent, "");
9198        assert_eq!(prefix, "/// ");
9199    }
9200
9201    #[test]
9202    fn detect_rust_inner_doc_comment() {
9203        let result = detect_comment_on_line("rust", "//! crate docs");
9204        assert!(result.is_some());
9205        let (_, prefix) = result.unwrap();
9206        assert_eq!(prefix, "//! ");
9207    }
9208
9209    #[test]
9210    fn detect_rust_plain_comment() {
9211        let result = detect_comment_on_line("rust", "// normal comment");
9212        assert!(result.is_some());
9213        let (_, prefix) = result.unwrap();
9214        assert_eq!(prefix, "// ");
9215    }
9216
9217    #[test]
9218    fn detect_indented_comment() {
9219        let result = detect_comment_on_line("rust", "    // indented");
9220        assert!(result.is_some());
9221        let (indent, prefix) = result.unwrap();
9222        assert_eq!(indent, "    ");
9223        assert_eq!(prefix, "// ");
9224    }
9225
9226    #[test]
9227    fn detect_python_hash() {
9228        let result = detect_comment_on_line("python", "# comment");
9229        assert!(result.is_some());
9230        let (_, prefix) = result.unwrap();
9231        assert_eq!(prefix, "# ");
9232    }
9233
9234    #[test]
9235    fn detect_lua_double_dash() {
9236        let result = detect_comment_on_line("lua", "-- a lua comment");
9237        assert!(result.is_some());
9238        let (_, prefix) = result.unwrap();
9239        assert_eq!(prefix, "-- ");
9240    }
9241
9242    #[test]
9243    fn detect_non_comment_is_none() {
9244        assert!(detect_comment_on_line("rust", "let x = 1;").is_none());
9245        assert!(detect_comment_on_line("python", "x = 1").is_none());
9246    }
9247
9248    #[test]
9249    fn detect_bare_double_slash_still_matches() {
9250        // A line that is exactly `//` with nothing after.
9251        assert!(detect_comment_on_line("rust", "//").is_some());
9252    }
9253
9254    #[test]
9255    fn rust_doc_before_plain() {
9256        // `///` must match before `//`.
9257        let result = detect_comment_on_line("rust", "/// outer doc");
9258        let (_, prefix) = result.unwrap();
9259        assert_eq!(prefix, "/// ", "/// must match before //");
9260    }
9261
9262    #[test]
9263    fn continue_comment_returns_prefix_for_comment_row() {
9264        let ed = make_editor_with_lang("rust", "/// hello\n");
9265        let cont = continue_comment(&ed.buffer, &ed.settings, 0);
9266        assert_eq!(cont, Some("/// ".to_string()));
9267    }
9268
9269    #[test]
9270    fn continue_comment_returns_none_for_non_comment() {
9271        let ed = make_editor_with_lang("rust", "let x = 1;\n");
9272        let cont = continue_comment(&ed.buffer, &ed.settings, 0);
9273        assert!(cont.is_none());
9274    }
9275
9276    #[test]
9277    fn continue_comment_returns_none_when_filetype_empty() {
9278        let buf = Buffer::from_str("// hello\n");
9279        let host = DefaultHost::new();
9280        // filetype defaults to "" in Options::default().
9281        let ed = Editor::new(buf, host, Options::default());
9282        let cont = continue_comment(&ed.buffer, &ed.settings, 0);
9283        assert!(cont.is_none());
9284    }
9285}
9286
9287#[cfg(test)]
9288mod comment_toggle_tests {
9289    use super::*;
9290    use crate::{DefaultHost, Editor, Options};
9291    use hjkl_buffer::Buffer;
9292
9293    fn make_rust_editor(content: &str) -> Editor<Buffer, DefaultHost> {
9294        let buf = Buffer::from_str(content);
9295        let host = DefaultHost::new();
9296        let opts = Options {
9297            filetype: "rust".to_string(),
9298            ..Options::default()
9299        };
9300        Editor::new(buf, host, opts)
9301    }
9302
9303    fn line(ed: &Editor<Buffer, DefaultHost>, row: usize) -> String {
9304        buf_line(&ed.buffer, row).unwrap_or_default()
9305    }
9306
9307    // ── gcc: toggle comment on current line ──────────────────────────────────
9308
9309    #[test]
9310    fn gcc_comments_rust_line() {
9311        let mut ed = make_rust_editor("let x = 1;");
9312        ed.toggle_comment_range(0, 0);
9313        assert_eq!(line(&ed, 0), "// let x = 1;");
9314    }
9315
9316    #[test]
9317    fn gcc_uncomments_rust_line() {
9318        let mut ed = make_rust_editor("// let x = 1;");
9319        ed.toggle_comment_range(0, 0);
9320        assert_eq!(line(&ed, 0), "let x = 1;");
9321    }
9322
9323    #[test]
9324    fn gcc_indent_preserving() {
9325        // Marker inserted after leading whitespace, not at column 0.
9326        let mut ed = make_rust_editor("    let x = 1;");
9327        ed.toggle_comment_range(0, 0);
9328        assert_eq!(line(&ed, 0), "    // let x = 1;");
9329    }
9330
9331    #[test]
9332    fn gcc_indent_preserving_uncomment() {
9333        let mut ed = make_rust_editor("    // let x = 1;");
9334        ed.toggle_comment_range(0, 0);
9335        assert_eq!(line(&ed, 0), "    let x = 1;");
9336    }
9337
9338    // ── Multi-line toggle ────────────────────────────────────────────────────
9339
9340    #[test]
9341    fn toggle_multi_line_all_uncommented() {
9342        let content = "let a = 1;\nlet b = 2;\nlet c = 3;";
9343        let mut ed = make_rust_editor(content);
9344        ed.toggle_comment_range(0, 2);
9345        assert_eq!(line(&ed, 0), "// let a = 1;");
9346        assert_eq!(line(&ed, 1), "// let b = 2;");
9347        assert_eq!(line(&ed, 2), "// let c = 3;");
9348    }
9349
9350    #[test]
9351    fn toggle_multi_line_all_commented() {
9352        let content = "// let a = 1;\n// let b = 2;\n// let c = 3;";
9353        let mut ed = make_rust_editor(content);
9354        ed.toggle_comment_range(0, 2);
9355        assert_eq!(line(&ed, 0), "let a = 1;");
9356        assert_eq!(line(&ed, 1), "let b = 2;");
9357        assert_eq!(line(&ed, 2), "let c = 3;");
9358    }
9359
9360    // ── Mixed state → all gets commented (vim-commentary parity) ────────────
9361
9362    #[test]
9363    fn toggle_mixed_state_comments_all() {
9364        // 3 uncommented + 2 commented → all 5 get commented.
9365        let content = "let a = 1;\n// let b = 2;\nlet c = 3;\n// let d = 4;\nlet e = 5;";
9366        let mut ed = make_rust_editor(content);
9367        ed.toggle_comment_range(0, 4);
9368        for r in 0..5 {
9369            assert!(
9370                line(&ed, r).trim_start().starts_with("//"),
9371                "row {r} not commented: {:?}",
9372                line(&ed, r)
9373            );
9374        }
9375    }
9376
9377    // ── Blank lines skipped ──────────────────────────────────────────────────
9378
9379    #[test]
9380    fn blank_lines_not_commented() {
9381        let content = "let a = 1;\n\nlet b = 2;";
9382        let mut ed = make_rust_editor(content);
9383        ed.toggle_comment_range(0, 2);
9384        assert_eq!(line(&ed, 0), "// let a = 1;");
9385        assert_eq!(line(&ed, 1), ""); // blank — untouched
9386        assert_eq!(line(&ed, 2), "// let b = 2;");
9387    }
9388
9389    // ── Python hash comments ─────────────────────────────────────────────────
9390
9391    #[test]
9392    fn python_comment_toggle() {
9393        let buf = Buffer::from_str("x = 1\ny = 2");
9394        let host = DefaultHost::new();
9395        let opts = Options {
9396            filetype: "python".to_string(),
9397            ..Options::default()
9398        };
9399        let mut ed = Editor::new(buf, host, opts);
9400        ed.toggle_comment_range(0, 1);
9401        assert_eq!(line(&ed, 0), "# x = 1");
9402        assert_eq!(line(&ed, 1), "# y = 2");
9403        // Toggle back.
9404        ed.toggle_comment_range(0, 1);
9405        assert_eq!(line(&ed, 0), "x = 1");
9406        assert_eq!(line(&ed, 1), "y = 2");
9407    }
9408
9409    // ── commentstring override ───────────────────────────────────────────────
9410
9411    #[test]
9412    fn commentstring_override_via_setting() {
9413        let buf = Buffer::from_str("hello world");
9414        let host = DefaultHost::new();
9415        let opts = Options {
9416            filetype: "rust".to_string(),
9417            ..Options::default()
9418        };
9419        let mut ed = Editor::new(buf, host, opts);
9420        // Override with a custom marker.
9421        ed.settings_mut().commentstring = "# %s".to_string();
9422        ed.toggle_comment_range(0, 0);
9423        assert_eq!(line(&ed, 0), "# hello world");
9424    }
9425
9426    // ── Unknown language → no-op ─────────────────────────────────────────────
9427
9428    #[test]
9429    fn unknown_lang_no_op() {
9430        let buf = Buffer::from_str("hello");
9431        let host = DefaultHost::new();
9432        let opts = Options::default(); // filetype = ""
9433        let mut ed = Editor::new(buf, host, opts);
9434        ed.toggle_comment_range(0, 0);
9435        // Should be unchanged — no comment string for "".
9436        assert_eq!(line(&ed, 0), "hello");
9437    }
9438}
9439
9440// ─── g& tests ─────────────────────────────────────────────────────────────
9441
9442#[cfg(test)]
9443mod g_ampersand_tests {
9444    use super::*;
9445    use crate::{DefaultHost, Editor, Options};
9446    use hjkl_buffer::{Buffer, rope_line_str};
9447
9448    fn make_editor(content: &str) -> Editor<Buffer, DefaultHost> {
9449        let buf = Buffer::from_str(content);
9450        let host = DefaultHost::new();
9451        Editor::new(buf, host, Options::default())
9452    }
9453
9454    fn buf_line(ed: &Editor<Buffer, DefaultHost>, row: usize) -> String {
9455        let rope = ed.buffer().rope();
9456        rope_line_str(&rope, row).trim_end_matches('\n').to_string()
9457    }
9458
9459    /// `g&` repeats last `:s/foo/bar/` over every line (no /g flag → first
9460    /// match per line only).
9461    #[test]
9462    fn g_ampersand_repeats_last_substitute_on_whole_buffer() {
9463        let mut ed = make_editor("foo\nfoo bar foo\nbaz");
9464        // Simulate a prior `:s/foo/bar/` by setting last_substitute directly.
9465        let cmd = crate::substitute::parse_substitute("/foo/bar/").unwrap();
9466        ed.set_last_substitute(cmd);
9467        // Cursor on line 0 (to confirm g& operates on ALL lines, not just current).
9468        apply_after_g(&mut ed, '&', 1);
9469        assert_eq!(buf_line(&ed, 0), "bar");
9470        // No /g flag — only first match per line.
9471        assert_eq!(buf_line(&ed, 1), "bar bar foo");
9472        assert_eq!(buf_line(&ed, 2), "baz");
9473    }
9474
9475    /// `g&` with /g flag replaces all matches per line.
9476    #[test]
9477    fn g_ampersand_with_g_flag_replaces_all_per_line() {
9478        let mut ed = make_editor("foo foo\nfoo");
9479        let cmd = crate::substitute::parse_substitute("/foo/bar/g").unwrap();
9480        ed.set_last_substitute(cmd);
9481        apply_after_g(&mut ed, '&', 1);
9482        assert_eq!(buf_line(&ed, 0), "bar bar");
9483        assert_eq!(buf_line(&ed, 1), "bar");
9484    }
9485
9486    /// `g&` with no prior substitute is a no-op.
9487    #[test]
9488    fn g_ampersand_noop_when_no_prior_substitute() {
9489        let mut ed = make_editor("foo\nbar");
9490        // No last_substitute set — must not panic, must not change buffer.
9491        apply_after_g(&mut ed, '&', 1);
9492        assert_eq!(buf_line(&ed, 0), "foo");
9493        assert_eq!(buf_line(&ed, 1), "bar");
9494    }
9495}
9496
9497// ─── Sneak motion tests ───────────────────────────────────────────────────
9498
9499#[cfg(test)]
9500mod sneak_tests {
9501    use super::*;
9502    use crate::{DefaultHost, Editor, Options};
9503    use hjkl_buffer::Buffer;
9504
9505    fn make_editor(content: &str) -> Editor<Buffer, DefaultHost> {
9506        let buf = Buffer::from_str(content);
9507        let host = DefaultHost::new();
9508        Editor::new(buf, host, Options::default())
9509    }
9510
9511    /// `s ba` from [0,0] on "foo bar baz qux\n" → cursor at [0,4] (start of "ba" in "bar").
9512    #[test]
9513    fn sneak_forward_jumps_to_two_char_digraph() {
9514        let mut ed = make_editor("foo bar baz qux\n");
9515        ed.jump_cursor(0, 0);
9516        ed.sneak('b', 'a', true, 1);
9517        assert_eq!(ed.cursor(), (0, 4), "cursor should land on 'ba' in 'bar'");
9518    }
9519
9520    /// `S ba` from [0,12] on "foo bar baz qux\n" → cursor at [0,8] ("ba" in "baz").
9521    #[test]
9522    fn sneak_backward_jumps_to_prior_match() {
9523        let mut ed = make_editor("foo bar baz qux\n");
9524        ed.jump_cursor(0, 12);
9525        ed.sneak('b', 'a', false, 1);
9526        assert_eq!(
9527            ed.cursor(),
9528            (0, 8),
9529            "backward sneak should find 'ba' in 'baz'"
9530        );
9531    }
9532
9533    /// After sneak forward to "bar", `;` (sneak-repeat) jumps to next "ba" ("baz").
9534    #[test]
9535    fn sneak_repeat_semicolon_next_match() {
9536        let mut ed = make_editor("foo bar baz qux\n");
9537        ed.jump_cursor(0, 0);
9538        // First sneak: lands at [0,4]
9539        ed.sneak('b', 'a', true, 1);
9540        assert_eq!(ed.cursor(), (0, 4));
9541        // Repeat via execute_motion FindRepeat (which routes through sneak if last was sneak)
9542        execute_motion(&mut ed, Motion::FindRepeat { reverse: false }, 1);
9543        assert_eq!(ed.cursor(), (0, 8), "semicolon should jump to next 'ba'");
9544    }
9545
9546    /// After sneak forward from [0,0] to [0,4], `,` (reverse) — no prior "ba" → stays.
9547    #[test]
9548    fn sneak_repeat_comma_prev_match() {
9549        let mut ed = make_editor("foo bar baz qux\n");
9550        ed.jump_cursor(0, 0);
9551        ed.sneak('b', 'a', true, 1);
9552        assert_eq!(ed.cursor(), (0, 4));
9553        // Reverse repeat — no "ba" before col 4, so cursor must not move.
9554        let pre = ed.cursor();
9555        execute_motion(&mut ed, Motion::FindRepeat { reverse: true }, 1);
9556        assert_eq!(
9557            ed.cursor(),
9558            pre,
9559            "comma with no prior match should leave cursor unchanged"
9560        );
9561    }
9562
9563    /// `S ba` from [0,12] jumps backward.
9564    #[test]
9565    fn sneak_s_searches_backward() {
9566        let mut ed = make_editor("foo bar baz qux\n");
9567        ed.jump_cursor(0, 12);
9568        ed.sneak('b', 'a', false, 1);
9569        assert_eq!(ed.cursor(), (0, 8));
9570    }
9571
9572    /// `2s ba` from [0,0] jumps to 2nd "ba" occurrence.
9573    #[test]
9574    fn sneak_with_count_jumps_to_nth() {
9575        let mut ed = make_editor("foo bar baz qux\n");
9576        ed.jump_cursor(0, 0);
9577        ed.sneak('b', 'a', true, 2);
9578        assert_eq!(ed.cursor(), (0, 8), "count=2 should jump to 2nd 'ba'");
9579    }
9580
9581    /// `s xx` with no match — cursor stays put.
9582    #[test]
9583    fn sneak_no_match_cursor_stays() {
9584        let mut ed = make_editor("foo bar baz qux\n");
9585        ed.jump_cursor(0, 0);
9586        let pre = ed.cursor();
9587        ed.sneak('x', 'x', true, 1);
9588        assert_eq!(ed.cursor(), pre, "no match should leave cursor unchanged");
9589    }
9590
9591    /// `dsab` on "hello ab world\n" from [0,0] → deletes up to 'ab', leaving "ab world\n".
9592    #[test]
9593    fn operator_pending_dsab_deletes_to_digraph() {
9594        let mut ed = make_editor("hello ab world\n");
9595        ed.jump_cursor(0, 0);
9596        ed.apply_op_sneak(Operator::Delete, 'a', 'b', true, 1);
9597        // Buffer content after exclusive delete from [0,0] to [0,6] (start of "ab").
9598        let content = ed.content();
9599        assert!(
9600            content.starts_with("ab world"),
9601            "dsab should delete 'hello ' leaving 'ab world'; got: {content:?}"
9602        );
9603    }
9604
9605    /// Cross-line sneak: "foo\nbar baz\n", cursor [0,0], `s ba` → [1,0].
9606    #[test]
9607    fn sneak_cross_line_match() {
9608        let mut ed = make_editor("foo\nbar baz\n");
9609        ed.jump_cursor(0, 0);
9610        ed.sneak('b', 'a', true, 1);
9611        assert_eq!(ed.cursor(), (1, 0), "sneak should cross line boundary");
9612    }
9613
9614    /// `last_sneak` is updated after `sneak()` so `;`/`,` can repeat.
9615    #[test]
9616    fn sneak_updates_last_sneak_state() {
9617        let mut ed = make_editor("foo bar baz\n");
9618        ed.jump_cursor(0, 0);
9619        ed.sneak('b', 'a', true, 1);
9620        let ls = ed.last_sneak();
9621        assert_eq!(
9622            ls,
9623            Some((('b', 'a'), true)),
9624            "last_sneak should record the digraph and direction"
9625        );
9626    }
9627}
9628
9629// ─── [count]>> / [count]<< line-operator count tests ──────────────────────
9630//
9631// vim semantics (captured from `nvim --headless`, mirrored by the
9632// `tier2_indent_count` oracle corpus):
9633//   - `[count]op` operates on `count` lines from the cursor, clamped to the
9634//     buffer end.
9635//   - The implied `count_` motion moves `count - 1` lines down; on the last
9636//     line it can't move, so `[count>=2]>>` / `<<` is a complete no-op (E16).
9637#[cfg(test)]
9638mod indent_count_tests {
9639    use super::*;
9640    use crate::{DefaultHost, Editor, Options};
9641    use hjkl_buffer::Buffer;
9642
9643    fn make_editor(content: &str) -> Editor<Buffer, DefaultHost> {
9644        let buf = Buffer::from_str(content);
9645        let mut ed = Editor::new(buf, DefaultHost::new(), Options::default());
9646        ed.settings_mut().expandtab = true;
9647        ed.settings_mut().shiftwidth = 4;
9648        ed
9649    }
9650
9651    fn content(ed: &Editor<Buffer, DefaultHost>) -> String {
9652        (*ed.buffer().content_joined()).clone()
9653    }
9654
9655    #[test]
9656    fn count_indent_operates_on_n_lines() {
9657        let mut ed = make_editor("a\nb\nc\nd\ne\nf\n");
9658        ed.jump_cursor(0, 0);
9659        execute_line_op(&mut ed, Operator::Indent, 3);
9660        assert_eq!(content(&ed), "    a\n    b\n    c\nd\ne\nf\n");
9661    }
9662
9663    // #263: `>>` under `noexpandtab` must insert a hard tab, not spaces.
9664    #[test]
9665    fn indent_noexpandtab_inserts_tab() {
9666        let mut ed = make_editor("hello\n");
9667        ed.settings_mut().expandtab = false;
9668        ed.settings_mut().shiftwidth = 4;
9669        ed.settings_mut().tabstop = 4;
9670        ed.jump_cursor(0, 0);
9671        execute_line_op(&mut ed, Operator::Indent, 1);
9672        assert_eq!(content(&ed), "\thello\n");
9673    }
9674
9675    // #263: a sub-tabstop shiftwidth under `noexpandtab` pads with spaces for
9676    // the remainder (shiftwidth=2 < tabstop=4 → two spaces, no tab).
9677    #[test]
9678    fn indent_noexpandtab_subtab_remainder_is_spaces() {
9679        let mut ed = make_editor("hello\n");
9680        ed.settings_mut().expandtab = false;
9681        ed.settings_mut().shiftwidth = 2;
9682        ed.settings_mut().tabstop = 4;
9683        ed.jump_cursor(0, 0);
9684        execute_line_op(&mut ed, Operator::Indent, 1);
9685        assert_eq!(content(&ed), "  hello\n");
9686    }
9687
9688    #[test]
9689    fn count_indent_clamps_to_buffer_end() {
9690        let mut ed = make_editor("a\nb\nc\nd\ne\nf\n");
9691        ed.jump_cursor(0, 0);
9692        execute_line_op(&mut ed, Operator::Indent, 10);
9693        assert_eq!(content(&ed), "    a\n    b\n    c\n    d\n    e\n    f\n");
9694    }
9695
9696    #[test]
9697    fn count_outdent_clamps_to_buffer_end() {
9698        let mut ed = make_editor("    a\n    b\n    c\n");
9699        ed.jump_cursor(0, 0);
9700        execute_line_op(&mut ed, Operator::Outdent, 10);
9701        assert_eq!(content(&ed), "a\nb\nc\n");
9702    }
9703
9704    #[test]
9705    fn count_indent_on_last_line_is_noop() {
9706        let mut ed = make_editor("a\nb\nc\n");
9707        ed.jump_cursor(2, 0); // last content line
9708        execute_line_op(&mut ed, Operator::Indent, 5);
9709        assert_eq!(
9710            content(&ed),
9711            "a\nb\nc\n",
9712            "5>> on last line must abort (E16)"
9713        );
9714    }
9715
9716    #[test]
9717    fn count_indent_on_single_line_is_noop() {
9718        let mut ed = make_editor("x\n");
9719        ed.jump_cursor(0, 0);
9720        execute_line_op(&mut ed, Operator::Indent, 5);
9721        assert_eq!(content(&ed), "x\n", "5>> on the only line must abort (E16)");
9722    }
9723
9724    #[test]
9725    fn count_outdent_on_last_line_is_noop() {
9726        let mut ed = make_editor("    a\n    b\n    c\n");
9727        ed.jump_cursor(2, 0);
9728        execute_line_op(&mut ed, Operator::Outdent, 5);
9729        assert_eq!(content(&ed), "    a\n    b\n    c\n");
9730    }
9731
9732    #[test]
9733    fn single_indent_on_last_line_still_works() {
9734        // count == 1 needs no motion, so `>>` on the last line indents it.
9735        let mut ed = make_editor("a\nb\nc\n");
9736        ed.jump_cursor(2, 0);
9737        execute_line_op(&mut ed, Operator::Indent, 1);
9738        assert_eq!(content(&ed), "a\nb\n    c\n");
9739    }
9740}
9741
9742// ── try_abbrev_expand unit tests ─────────────────────────────────────────────
9743
9744#[cfg(test)]
9745mod abbrev_tests {
9746    use super::{Abbrev, AbbrevKind, AbbrevTrigger, abbrev_kind, try_abbrev_expand};
9747    use AbbrevKind::{End, Full, NonKw};
9748
9749    const ISK: &str = "@,48-57,_,192-255"; // default iskeyword
9750
9751    fn make_abbrev(lhs: &str, rhs: &str) -> Abbrev {
9752        Abbrev {
9753            lhs: lhs.to_string(),
9754            rhs: rhs.to_string(),
9755            insert: true,
9756            cmdline: false,
9757            noremap: false,
9758        }
9759    }
9760
9761    fn expand(
9762        abbrevs: &[Abbrev],
9763        before: &str,
9764        mincol: usize,
9765        trig: AbbrevTrigger,
9766    ) -> Option<(usize, String)> {
9767        try_abbrev_expand(abbrevs, before, mincol, trig, ISK)
9768    }
9769
9770    // ── abbrev_type classification ────────────────────────────────────────────
9771
9772    #[test]
9773    fn fullid_all_keyword_chars() {
9774        assert_eq!(abbrev_kind("teh", ISK), Full);
9775        assert_eq!(abbrev_kind("abc123", ISK), Full);
9776        assert_eq!(abbrev_kind("_foo", ISK), Full);
9777    }
9778
9779    #[test]
9780    fn endid_ends_with_kw_has_nonkw() {
9781        assert_eq!(abbrev_kind("#i", ISK), End);
9782        assert_eq!(abbrev_kind("#include", ISK), End);
9783    }
9784
9785    #[test]
9786    fn nonid_ends_with_nonkw() {
9787        assert_eq!(abbrev_kind(";;", ISK), NonKw);
9788        assert_eq!(abbrev_kind("->", ISK), NonKw);
9789    }
9790
9791    // ── full-id expansion ─────────────────────────────────────────────────────
9792
9793    #[test]
9794    fn fullid_expands_on_space_trigger() {
9795        let abbrevs = [make_abbrev("teh", "the")];
9796        let r = expand(&abbrevs, "teh", 0, AbbrevTrigger::NonKeyword(' '));
9797        assert_eq!(r, Some((3, "the".to_string())));
9798    }
9799
9800    #[test]
9801    fn fullid_expands_on_esc_trigger() {
9802        let abbrevs = [make_abbrev("teh", "the")];
9803        let r = expand(&abbrevs, "teh", 0, AbbrevTrigger::Esc);
9804        assert_eq!(r, Some((3, "the".to_string())));
9805    }
9806
9807    #[test]
9808    fn fullid_expands_on_cr_trigger() {
9809        let abbrevs = [make_abbrev("teh", "the")];
9810        let r = expand(&abbrevs, "teh", 0, AbbrevTrigger::Cr);
9811        assert_eq!(r, Some((3, "the".to_string())));
9812    }
9813
9814    #[test]
9815    fn fullid_expands_on_ctrl_bracket() {
9816        let abbrevs = [make_abbrev("teh", "the")];
9817        let r = expand(&abbrevs, "teh", 0, AbbrevTrigger::CtrlBracket);
9818        assert_eq!(r, Some((3, "the".to_string())));
9819    }
9820
9821    #[test]
9822    fn fullid_does_not_expand_on_keyword_trigger() {
9823        // Typing a keyword char after "teh" would extend the word — no expand.
9824        let abbrevs = [make_abbrev("teh", "the")];
9825        let r = expand(&abbrevs, "teh", 0, AbbrevTrigger::NonKeyword('a'));
9826        // 'a' is keyword — should not trigger
9827        assert_eq!(r, None);
9828    }
9829
9830    #[test]
9831    fn fullid_no_expand_when_lhs_not_at_end() {
9832        let abbrevs = [make_abbrev("teh", "the")];
9833        // "ateh" — 'a' before is keyword, so skip.
9834        let r = expand(&abbrevs, "ateh", 0, AbbrevTrigger::NonKeyword(' '));
9835        assert_eq!(r, None);
9836    }
9837
9838    #[test]
9839    fn fullid_expands_after_nonkw_prefix() {
9840        let abbrevs = [make_abbrev("teh", "the")];
9841        // "!teh" — '!' before is non-keyword → expand.
9842        let r = expand(&abbrevs, "!teh", 0, AbbrevTrigger::NonKeyword(' '));
9843        assert_eq!(r, Some((3, "the".to_string())));
9844    }
9845
9846    #[test]
9847    fn fullid_single_char_no_expand_after_nonblank_nonkw() {
9848        let abbrevs = [make_abbrev("a", "b")];
9849        // "!a" — '!' is non-blank non-keyword before single-char lhs → no expand.
9850        let r = expand(&abbrevs, "!a", 0, AbbrevTrigger::NonKeyword(' '));
9851        assert_eq!(r, None);
9852    }
9853
9854    #[test]
9855    fn fullid_single_char_expands_after_space() {
9856        let abbrevs = [make_abbrev("a", "b")];
9857        // " a" — space before single-char lhs → expand.
9858        let r = expand(&abbrevs, " a", 0, AbbrevTrigger::NonKeyword(' '));
9859        assert_eq!(r, Some((1, "b".to_string())));
9860    }
9861
9862    // ── mincol: pre-existing text must not be consumed ────────────────────────
9863
9864    #[test]
9865    fn mincol_blocks_consuming_preexisting_text() {
9866        let abbrevs = [make_abbrev("teh", "the")];
9867        // "teh" is at cols 0..3, but insert started at col 3 → no match.
9868        let r = expand(&abbrevs, "teh", 3, AbbrevTrigger::NonKeyword(' '));
9869        assert_eq!(r, None);
9870    }
9871
9872    #[test]
9873    fn mincol_allows_match_starting_at_mincol() {
9874        let abbrevs = [make_abbrev("teh", "the")];
9875        // Existing text "!! " at 0..3, then user typed "teh" → mincol=3.
9876        // The char before the lhs is ' ' (non-keyword), so full-id expands.
9877        let r = expand(&abbrevs, "!! teh", 3, AbbrevTrigger::NonKeyword(' '));
9878        assert_eq!(r, Some((3, "the".to_string())));
9879    }
9880
9881    // ── end-id expansion ──────────────────────────────────────────────────────
9882
9883    #[test]
9884    fn endid_expands_on_space_trigger() {
9885        let abbrevs = [make_abbrev("#i", "#include")];
9886        let r = expand(&abbrevs, "#i", 0, AbbrevTrigger::NonKeyword(' '));
9887        assert_eq!(r, Some((2, "#include".to_string())));
9888    }
9889
9890    #[test]
9891    fn endid_expands_on_esc_trigger() {
9892        let abbrevs = [make_abbrev("#i", "#include")];
9893        let r = expand(&abbrevs, "#i", 0, AbbrevTrigger::Esc);
9894        assert_eq!(r, Some((2, "#include".to_string())));
9895    }
9896
9897    // ── non-id expansion ──────────────────────────────────────────────────────
9898
9899    #[test]
9900    fn nonid_expands_on_esc_trigger() {
9901        let abbrevs = [make_abbrev(";;", "std::endl;")];
9902        let r = expand(&abbrevs, ";;", 0, AbbrevTrigger::Esc);
9903        assert_eq!(r, Some((2, "std::endl;".to_string())));
9904    }
9905
9906    #[test]
9907    fn nonid_expands_on_cr_trigger() {
9908        let abbrevs = [make_abbrev(";;", "std::endl;")];
9909        let r = expand(&abbrevs, ";;", 0, AbbrevTrigger::Cr);
9910        assert_eq!(r, Some((2, "std::endl;".to_string())));
9911    }
9912
9913    #[test]
9914    fn nonid_does_not_expand_on_nonkw_trigger() {
9915        // non-id abbreviations must NOT expand on regular typed chars like space.
9916        let abbrevs = [make_abbrev(";;", "std::endl;")];
9917        let r = expand(&abbrevs, ";;", 0, AbbrevTrigger::NonKeyword(' '));
9918        assert_eq!(r, None);
9919    }
9920
9921    #[test]
9922    fn nonid_expands_on_ctrl_bracket() {
9923        let abbrevs = [make_abbrev(";;", "std::endl;")];
9924        let r = expand(&abbrevs, ";;", 0, AbbrevTrigger::CtrlBracket);
9925        assert_eq!(r, Some((2, "std::endl;".to_string())));
9926    }
9927
9928    // ── multiword rhs ─────────────────────────────────────────────────────────
9929
9930    #[test]
9931    fn multiword_rhs_expansion() {
9932        let abbrevs = [make_abbrev("hw", "hello world")];
9933        let r = expand(&abbrevs, "hw", 0, AbbrevTrigger::NonKeyword(' '));
9934        assert_eq!(r, Some((2, "hello world".to_string())));
9935    }
9936
9937    // ── empty / no match ─────────────────────────────────────────────────────
9938
9939    #[test]
9940    fn no_match_returns_none() {
9941        let abbrevs = [make_abbrev("teh", "the")];
9942        let r = expand(&abbrevs, "xyz", 0, AbbrevTrigger::NonKeyword(' '));
9943        assert_eq!(r, None);
9944    }
9945
9946    #[test]
9947    fn empty_abbrevs_returns_none() {
9948        let r = expand(&[], "teh", 0, AbbrevTrigger::NonKeyword(' '));
9949        assert_eq!(r, None);
9950    }
9951
9952    #[test]
9953    fn empty_before_text_returns_none() {
9954        let abbrevs = [make_abbrev("teh", "the")];
9955        let r = expand(&abbrevs, "", 0, AbbrevTrigger::NonKeyword(' '));
9956        assert_eq!(r, None);
9957    }
9958}
9959
9960// ─── scan_tag_opener / autoclose multibyte tests ──────────────────────────────
9961
9962#[cfg(test)]
9963mod scan_tag_opener_multibyte_tests {
9964    use crate::types::Options;
9965    use crate::{DefaultHost, Editor};
9966    use hjkl_buffer::Buffer;
9967
9968    fn html_editor(content: &str) -> Editor<Buffer, DefaultHost> {
9969        let buf = Buffer::from_str(content);
9970        let host = DefaultHost::new();
9971        let mut ed = Editor::new(buf, host, Options::default());
9972        ed.settings.filetype = "html".to_string();
9973        ed.settings.autoclose_tag = true;
9974        ed.settings.autopair = true;
9975        ed
9976    }
9977
9978    /// Typing `>` after a multibyte char must not panic.
9979    ///
9980    /// With "ñ" in the buffer (ñ = 2 UTF-8 bytes), the cursor is at char
9981    /// col 1 (one past ñ).  `insert_char('>')` calls `scan_tag_opener` with
9982    /// `col = cursor.col = 1`.  Before the fix, `&line[..1]` landed inside
9983    /// the 2-byte ñ sequence → panic "byte index 1 is not a char boundary".
9984    #[test]
9985    fn autoclose_gt_after_multibyte_no_panic() {
9986        let mut ed = html_editor("ñ");
9987        // Cursor starts at col 0 (on ñ). Enter insert at end-of-line.
9988        ed.enter_insert_i(1);
9989        // Move to end (col 1, after ñ).
9990        ed.jump_cursor(0, 1);
9991        // Insert '>' — must not panic.
9992        ed.insert_char('>');
9993        // The `>` should be in the buffer (no autoclose tag fires for bare ">").
9994        let rope = ed.buffer().rope();
9995        let line = hjkl_buffer::rope_line_str(&rope, 0);
9996        assert!(line.contains('>'), "inserted > must appear in buffer");
9997    }
9998
9999    /// Same repro via the direct tag-autoclose path.
10000    ///
10001    /// "ä<div" has a multibyte char at the start.  Positioning the cursor
10002    /// at char col 5 (after 'v') and inserting '>' exercises the
10003    /// scan_tag_opener branch.  Before the fix, `col = cursor.col = 5` and
10004    /// `&line[..5]` is byte index 5, which falls inside 'ä' (2 bytes at
10005    /// positions 0-1) — wait, 'ä'=2 bytes then '<','d','i','v' are 1 byte
10006    /// each, so byte 5 = 'v' (valid boundary).  Use a CJK char (3 bytes)
10007    /// to force a panic at a narrower position:
10008    ///
10009    /// "中<div>": 中 = 3 bytes; after '>', char col 5 → byte index 5.
10010    /// Bytes: 中=0,1,2  <=3  d=4  i=5  v=6  >=7.  Char index 4 = 'i', byte 4
10011    /// is safe. Need char 2 to map to byte 5 → that's inside '<'.
10012    ///
10013    /// Simplest panic case: "ñ>" (ñ=2 bytes, >=1 byte):
10014    /// char 0=ñ, char 1=>; cursor.col=1, &line[..1] = byte 1 = 0xb1 inside ñ → PANIC.
10015    #[test]
10016    fn autoclose_gt_direct_after_multibyte_no_panic() {
10017        // "ñ>" already in buffer — cursor at char col 1 (the '>').
10018        // We'll test by inserting '>' after 'ñ' from scratch.
10019        let mut ed = html_editor("ñ");
10020        ed.enter_insert_i(1);
10021        ed.jump_cursor(0, 1); // char col 1 = one past ñ
10022        // Insert '>' — before fix: scan_tag_opener("ñ>", 1) → &"ñ>"[..1] panics.
10023        ed.insert_char('>');
10024        let rope = ed.buffer().rope();
10025        let line = hjkl_buffer::rope_line_str(&rope, 0);
10026        assert!(
10027            line.contains('>'),
10028            "inserted > must appear in buffer, got: {line:?}"
10029        );
10030    }
10031}