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/// ROT13 a string: rotate ASCII letters by 13, leave everything else.
94pub(crate) fn rot13_str(s: &str) -> String {
95    s.chars()
96        .map(|c| match c {
97            'a'..='z' => (((c as u8 - b'a' + 13) % 26) + b'a') as char,
98            'A'..='Z' => (((c as u8 - b'A' + 13) % 26) + b'A') as char,
99            _ => c,
100        })
101        .collect()
102}
103
104// ─── Dot-repeat storage ────────────────────────────────────────────────────
105
106// ─── VimState ──────────────────────────────────────────────────────────────
107
108#[derive(Default)]
109pub struct VimState {
110    /// Internal FSM mode. Kept in sync with `current_mode` after every
111    /// `step`. Phase 6.6b: promoted from private to `pub` so the FSM
112    /// body (moving to hjkl-vim in 6.6c–6.6g) can read/write it directly
113    /// until the migration is complete.
114    pub mode: Mode,
115    /// Two-key chord in progress. `Pending::None` when idle.
116    pub pending: Pending,
117    /// Digit prefix accumulated before an operator or motion. `0` means
118    /// no prefix was typed (treated as 1 by most commands).
119    pub count: usize,
120    /// Last `f`/`F`/`t`/`T` target, for `;` / `,` repeat.
121    pub last_find: Option<(char, bool, bool)>,
122    /// Most-recent mutating command for `.` dot-repeat.
123    pub last_change: Option<LastChange>,
124    /// Captured on insert-mode entry: count, buffer snapshot, entry kind.
125    pub insert_session: Option<InsertSession>,
126    /// (row, col) anchor for char-wise Visual mode. Set on entry, used
127    /// to compute the highlight range and the operator range without
128    /// relying on tui-textarea's live selection.
129    pub visual_anchor: (usize, usize),
130    /// Row anchor for VisualLine mode.
131    pub visual_line_anchor: usize,
132    /// (row, col) anchor for VisualBlock mode. The live cursor is the
133    /// opposite corner.
134    pub block_anchor: (usize, usize),
135    /// Intended "virtual" column for the block's active corner. j/k
136    /// clamp cursor.col to shorter rows, which would collapse the
137    /// block across ragged content — so we remember the desired column
138    /// separately and use it for block bounds / insert-column
139    /// computations. Updated by h/l only.
140    pub block_vcol: usize,
141    /// Track whether the last yank/cut was linewise (drives `p`/`P` layout).
142    pub yank_linewise: bool,
143    /// Active register selector — set by `"reg` prefix, consumed by
144    /// the next y / d / c / p. `None` falls back to the unnamed `"`.
145    pub pending_register: Option<char>,
146    /// Recording target — set by `q{reg}`, cleared by a bare `q`.
147    /// While `Some`, every consumed `Input` is appended to
148    /// `recording_keys`.
149    pub recording_macro: Option<char>,
150    /// Keys recorded into the in-progress macro. On `q` finish, these
151    /// are encoded via [`crate::input::encode_macro`] and written to
152    /// the matching named register slot, so macros and yanks share a
153    /// single store.
154    pub recording_keys: Vec<crate::input::Input>,
155    /// Set during `@reg` replay so the recorder doesn't capture the
156    /// replayed keystrokes a second time.
157    pub replaying_macro: bool,
158    /// Last register played via `@reg`. `@@` re-plays this one.
159    pub last_macro: Option<char>,
160    /// Position where the cursor was when insert mode last exited (Esc).
161    /// Used by `gi` to return to the exact (row, col) where the user
162    /// last typed, matching vim's `:h gi`.
163    pub last_insert_pos: Option<(usize, usize)>,
164    /// Snapshot of the last visual selection for `gv` re-entry.
165    /// Stored on every Visual / VisualLine / VisualBlock exit.
166    pub last_visual: Option<LastVisual>,
167    /// `zz` / `zt` / `zb` set this so the end-of-step scrolloff
168    /// pass doesn't override the user's explicit viewport pinning.
169    /// Cleared every step.
170    pub viewport_pinned: bool,
171    /// Set by the 7 smooth-scrollable motions (C-d/u/f/b, zz/zt/zb) so the
172    /// app can animate the viewport jump. Drained via Editor::take_scroll_anim_hint.
173    pub scroll_anim_hint: bool,
174    /// Set while replaying `.` / last-change so we don't re-record it.
175    pub replaying: bool,
176    /// Entered Normal from Insert via `Ctrl-o`; after the next complete
177    /// normal-mode command we return to Insert.
178    pub one_shot_normal: bool,
179    /// Live `/` or `?` prompt. `None` outside search-prompt mode.
180    pub search_prompt: Option<SearchPrompt>,
181    /// Most recent committed search pattern. Surfaced to host apps via
182    /// [`Editor::last_search`] so their status line can render a hint
183    /// and so `n` / `N` have something to repeat.
184    pub last_search: Option<String>,
185    /// Direction of the last committed search. `n` repeats this; `N`
186    /// inverts it. Defaults to forward so a never-searched buffer's
187    /// `n` still walks downward.
188    pub last_search_forward: bool,
189    /// Text of the most recent insert session — vim's `".` register, pasted
190    /// via `<C-r>.` in insert mode (and `".p` in normal mode).
191    pub last_insert_text: Option<String>,
192    /// Back half of the jumplist — `Ctrl-o` pops from here. Populated
193    /// with the pre-motion cursor when a "big jump" motion fires
194    /// (`gg`/`G`, `%`, `*`/`#`, `n`/`N`, `H`/`M`/`L`, committed `/` or
195    /// `?`). Capped at 100 entries.
196    pub jump_back: Vec<(usize, usize)>,
197    /// Forward half — `Ctrl-i` pops from here. Cleared by any new big
198    /// jump, matching vim's "branch off trims forward history" rule.
199    pub jump_fwd: Vec<(usize, usize)>,
200    /// Set by `Ctrl-R` in insert mode while waiting for the register
201    /// selector. The next typed char names the register; its contents
202    /// are inserted inline at the cursor and the flag clears.
203    pub insert_pending_register: bool,
204    /// Stashed start position for the `[` mark on a Change operation.
205    /// Set to `top` before the cut in `run_operator_over_range` (Change
206    /// arm); consumed by `finish_insert_session` on Esc-from-insert
207    /// when the reason is `AfterChange`. Mirrors vim's `:h '[` / `:h ']`
208    /// rule that `[` = start of change, `]` = last typed char on exit.
209    pub change_mark_start: Option<(usize, usize)>,
210    /// Bounded history of committed `/` / `?` search patterns. Newest
211    /// entries are at the back; capped at [`SEARCH_HISTORY_MAX`] to
212    /// avoid unbounded growth on long sessions.
213    pub search_history: Vec<String>,
214    /// Index into `search_history` while the user walks past patterns
215    /// in the prompt via `Ctrl-P` / `Ctrl-N`. `None` outside that walk
216    /// — typing or backspacing in the prompt resets it so the next
217    /// `Ctrl-P` starts from the most recent entry again.
218    pub search_history_cursor: Option<usize>,
219    /// Wall-clock instant of the last keystroke. Drives the
220    /// `:set timeoutlen` multi-key timeout — if `now() - last_input_at`
221    /// exceeds the configured budget, any pending prefix is cleared
222    /// before the new key dispatches. `None` before the first key.
223    /// 0.0.29 (Patch B): `:set timeoutlen` math now reads
224    /// [`crate::types::Host::now`] via `last_input_host_at`. This
225    /// `Instant`-flavoured field stays for snapshot tests that still
226    /// observe it directly.
227    pub last_input_at: Option<std::time::Instant>,
228    /// `Host::now()` reading at the last keystroke. Drives
229    /// `:set timeoutlen` so macro replay / headless drivers stay
230    /// deterministic regardless of wall-clock skew.
231    pub last_input_host_at: Option<core::time::Duration>,
232    /// Canonical current mode. Mirrors `mode` (the FSM-internal field)
233    /// AND is written by every Phase 6.3 primitive (`set_mode`,
234    /// `enter_visual_char_bridge`, …). Once the FSM is gone this is the
235    /// sole source of truth; until then both fields are kept in sync.
236    /// Initialized to `Normal` via `#[derive(Default)]`.
237    pub current_mode: crate::VimMode,
238    /// Most recent successful :s invocation. Stored so :& / :&& can repeat it.
239    pub last_substitute: Option<crate::substitute::SubstituteCmd>,
240    /// Stack of auto-inserted closing characters awaiting skip-over.
241    ///
242    /// Each entry `(row, col, ch)` records where autopair placed a close
243    /// character. When the next typed char matches `ch` AND the cursor is
244    /// immediately before that position, the engine advances past it
245    /// ("skip-over") instead of inserting. The stack is cleared on any
246    /// cursor motion, mode change, or out-of-pair edit.
247    pub pending_closes: Vec<(usize, usize, char)>,
248    /// Last sneak digraph and direction: `Some(((c1, c2), forward))`.
249    /// Used by `;` / `,` sneak-repeat when `last_horizontal_motion == Sneak`.
250    pub last_sneak: Option<((char, char), bool)>,
251    /// Tracks which kind of horizontal motion was last performed, so `;` / `,`
252    /// can dispatch to sneak-repeat vs. find-char-repeat as appropriate.
253    pub last_horizontal_motion: LastHorizontalMotion,
254    /// Insert-mode (and cmdline-mode) abbreviations. Populated by `:abbreviate`,
255    /// `:iabbrev`, `:cabbrev`, `:noreabbrev`, etc. Empty by default.
256    pub abbrevs: Vec<Abbrev>,
257}
258
259impl VimState {
260    pub fn public_mode(&self) -> VimMode {
261        match self.mode {
262            Mode::Normal => VimMode::Normal,
263            Mode::Insert => VimMode::Insert,
264            Mode::Visual => VimMode::Visual,
265            Mode::VisualLine => VimMode::VisualLine,
266            Mode::VisualBlock => VimMode::VisualBlock,
267        }
268    }
269
270    pub fn force_normal(&mut self) {
271        self.mode = Mode::Normal;
272        self.pending = Pending::None;
273        self.count = 0;
274        self.insert_session = None;
275        // Phase 6.3: keep current_mode in sync for callers that bypass step().
276        self.current_mode = crate::VimMode::Normal;
277    }
278
279    /// Reset every prefix-tracking field so the next keystroke starts
280    /// a fresh sequence. Drives `:set timeoutlen` — when the user
281    /// pauses past the configured budget, `hjkl_vim::dispatch_input` calls
282    /// this before dispatching the new key.
283    ///
284    /// Resets: `pending`, `count`, `pending_register`,
285    /// `insert_pending_register`. Does NOT touch `mode`,
286    /// `insert_session`, marks, jump list, or visual anchors —
287    /// those aren't part of the in-flight chord.
288    pub fn clear_pending_prefix(&mut self) {
289        self.pending = Pending::None;
290        self.count = 0;
291        self.pending_register = None;
292        self.insert_pending_register = false;
293    }
294
295    /// Widen the active insert session's row window to include `row`. Called
296    /// by the Phase 6.1 public `Editor::insert_*` methods after each
297    /// mutation so `finish_insert_session` diffs the right range on Esc.
298    /// No-op when no insert session is active (e.g. calling from Normal mode).
299    pub(crate) fn widen_insert_row(&mut self, row: usize) {
300        if let Some(ref mut session) = self.insert_session {
301            session.row_min = session.row_min.min(row);
302            session.row_max = session.row_max.max(row);
303        }
304    }
305
306    pub fn is_visual(&self) -> bool {
307        matches!(
308            self.mode,
309            Mode::Visual | Mode::VisualLine | Mode::VisualBlock
310        )
311    }
312
313    pub fn is_visual_char(&self) -> bool {
314        self.mode == Mode::Visual
315    }
316
317    /// The pending repeat count (typed digits before a motion/operator),
318    /// or `None` when no digits are pending. Zero is treated as absent.
319    pub(crate) fn pending_count_val(&self) -> Option<u32> {
320        if self.count == 0 {
321            None
322        } else {
323            Some(self.count as u32)
324        }
325    }
326
327    /// `true` when an in-flight chord is awaiting more keys. Inverse of
328    /// `matches!(self.pending, Pending::None)`.
329    pub(crate) fn is_chord_pending(&self) -> bool {
330        !matches!(self.pending, Pending::None)
331    }
332
333    /// Return a single char representing the pending operator, if any.
334    /// Used by host apps (status line "showcmd" area) to display e.g.
335    /// `d`, `y`, `c` while waiting for a motion.
336    pub(crate) fn pending_op_char(&self) -> Option<char> {
337        let op = match &self.pending {
338            Pending::Op { op, .. }
339            | Pending::OpTextObj { op, .. }
340            | Pending::OpG { op, .. }
341            | Pending::OpFind { op, .. }
342            | Pending::OpSquareBracketOpen { op, .. }
343            | Pending::OpSquareBracketClose { op, .. } => Some(*op),
344            _ => None,
345        };
346        op.map(|o| match o {
347            Operator::Delete => 'd',
348            Operator::Change => 'c',
349            Operator::Yank => 'y',
350            Operator::Uppercase => 'U',
351            Operator::Lowercase => 'u',
352            Operator::ToggleCase => '~',
353            Operator::Indent => '>',
354            Operator::Outdent => '<',
355            Operator::Fold => 'z',
356            Operator::Reflow => 'q',
357            Operator::ReflowKeepCursor => 'w',
358            Operator::AutoIndent => '=',
359            Operator::Filter => '!',
360            // `gc` prefix — doubled as `gcc`.
361            Operator::Comment => 'c',
362            // `g?` prefix — doubled as `g??`.
363            Operator::Rot13 => '?',
364        })
365    }
366}
367
368// ─── Entry point ───────────────────────────────────────────────────────────
369
370/// Open the `/` (forward) or `?` (backward) search prompt. Clears any
371/// live search highlight until the user commits a query. `last_search`
372/// is preserved so an empty `<CR>` can re-run the previous pattern.
373pub(crate) fn enter_search<H: crate::types::Host>(
374    ed: &mut Editor<hjkl_buffer::Buffer, H>,
375    forward: bool,
376) {
377    ed.vim.search_prompt = Some(SearchPrompt {
378        text: String::new(),
379        cursor: 0,
380        forward,
381        operator: None,
382    });
383    ed.vim.search_history_cursor = None;
384    // 0.0.37: clear via the engine search state (the buffer-side
385    // bridge from 0.0.35 was removed in this patch — the `BufferView`
386    // renderer reads the pattern from `Editor::search_state()`).
387    ed.set_search_pattern(None);
388}
389
390/// `d/pat` / `c/pat` / `y/pat` (and `?` forms) — open the search prompt in
391/// operator-pending mode. On commit the operator runs over the exclusive
392/// charwise range from the current cursor to the match.
393pub(crate) fn enter_search_op<H: crate::types::Host>(
394    ed: &mut Editor<hjkl_buffer::Buffer, H>,
395    forward: bool,
396    op: Operator,
397    count: usize,
398) {
399    let origin = ed.cursor();
400    ed.vim.search_prompt = Some(SearchPrompt {
401        text: String::new(),
402        cursor: 0,
403        forward,
404        operator: Some((op, count.max(1), origin)),
405    });
406    ed.vim.search_history_cursor = None;
407    ed.set_search_pattern(None);
408}
409
410/// Apply a pending operator-search over the exclusive charwise range from
411/// `origin` to the current (post-search) cursor. Used by the search-prompt
412/// commit path for `d/` / `c/` / `y/`.
413pub(crate) fn apply_op_search_range<H: crate::types::Host>(
414    ed: &mut Editor<hjkl_buffer::Buffer, H>,
415    op: Operator,
416    origin: (usize, usize),
417) {
418    let target = ed.cursor();
419    run_operator_over_range(ed, op, origin, target, RangeKind::Exclusive);
420}
421
422/// `g;` / `g,` body. `dir = -1` walks toward older entries (g;),
423/// `dir = 1` toward newer (g,). `count` repeats the step. Stops at
424/// the ends of the ring; off-ring positions are silently ignored.
425fn walk_change_list<H: crate::types::Host>(
426    ed: &mut Editor<hjkl_buffer::Buffer, H>,
427    dir: isize,
428    count: usize,
429) {
430    if ed.change_list.is_empty() {
431        return;
432    }
433    let len = ed.change_list.len();
434    let mut idx: isize = match (ed.change_list_cursor, dir) {
435        (None, -1) => len as isize - 1,
436        (None, 1) => return, // already past the newest entry
437        (Some(i), -1) => i as isize - 1,
438        (Some(i), 1) => i as isize + 1,
439        _ => return,
440    };
441    for _ in 1..count {
442        let next = idx + dir;
443        if next < 0 || next >= len as isize {
444            break;
445        }
446        idx = next;
447    }
448    if idx < 0 || idx >= len as isize {
449        return;
450    }
451    let idx = idx as usize;
452    ed.change_list_cursor = Some(idx);
453    let (row, col) = ed.change_list[idx];
454    ed.jump_cursor(row, col);
455}
456
457/// `Ctrl-R {reg}` body — insert the named register's contents at the
458/// cursor as charwise text. Embedded newlines split lines naturally via
459/// `Edit::InsertStr`. Unknown selectors and empty slots are no-ops so
460/// stray keystrokes don't mutate the buffer.
461fn insert_register_text<H: crate::types::Host>(
462    ed: &mut Editor<hjkl_buffer::Buffer, H>,
463    selector: char,
464) {
465    use hjkl_buffer::Edit;
466    // Special read-only registers: `/` = last search pattern, `.` = last
467    // inserted text. Fall back to the register store for everything else.
468    let text = match selector {
469        '/' => match &ed.vim.last_search {
470            Some(s) if !s.is_empty() => s.clone(),
471            _ => return,
472        },
473        '.' => match &ed.vim.last_insert_text {
474            Some(s) if !s.is_empty() => s.clone(),
475            _ => return,
476        },
477        _ => match ed.registers().read(selector) {
478            Some(slot) if !slot.text.is_empty() => slot.text.clone(),
479            _ => return,
480        },
481    };
482    ed.sync_buffer_content_from_textarea();
483    let cursor = buf_cursor_pos(&ed.buffer);
484    ed.mutate_edit(Edit::InsertStr {
485        at: cursor,
486        text: text.clone(),
487    });
488    // Advance cursor to the end of the inserted payload — multi-line
489    // pastes land on the last inserted row at the post-text column.
490    let mut row = cursor.row;
491    let mut col = cursor.col;
492    for ch in text.chars() {
493        if ch == '\n' {
494            row += 1;
495            col = 0;
496        } else {
497            col += 1;
498        }
499    }
500    buf_set_cursor_rc(&mut ed.buffer, row, col);
501    ed.push_buffer_cursor_to_textarea();
502    ed.mark_content_dirty();
503    if let Some(ref mut session) = ed.vim.insert_session {
504        session.row_min = session.row_min.min(row);
505        session.row_max = session.row_max.max(row);
506    }
507}
508
509/// Compute the indent string to insert at the start of a new line
510/// after Enter is pressed at `cursor`. Walks the smartindent rules:
511///
512/// - autoindent off → empty string
513/// - autoindent on  → copy prev line's leading whitespace
514/// - smartindent on → bump one `shiftwidth` if prev line's last
515///   non-whitespace char is `{` / `(` / `[`
516///
517/// Indent unit (used for the smartindent bump):
518///
519/// - `expandtab && softtabstop > 0` → `softtabstop` spaces
520/// - `expandtab` → `shiftwidth` spaces
521/// - `!expandtab` → one literal `\t`
522///
523/// This is the placeholder for a future tree-sitter indent provider:
524/// when a language has an `indents.scm` query, the engine will route
525/// the same call through that provider and only fall back to this
526/// heuristic when no query matches.
527pub(super) fn compute_enter_indent(settings: &crate::editor::Settings, prev_line: &str) -> String {
528    if !settings.autoindent {
529        return String::new();
530    }
531    // Copy the prev line's leading whitespace (autoindent base).
532    let base: String = prev_line
533        .chars()
534        .take_while(|c| *c == ' ' || *c == '\t')
535        .collect();
536
537    if settings.smartindent {
538        let unit = if settings.expandtab {
539            if settings.softtabstop > 0 {
540                " ".repeat(settings.softtabstop)
541            } else {
542                " ".repeat(settings.shiftwidth)
543            }
544        } else {
545            "\t".to_string()
546        };
547
548        // Open-bracket bump — language-agnostic.
549        let last_non_ws = prev_line.chars().rev().find(|c| !c.is_whitespace());
550        if matches!(last_non_ws, Some('{' | '(' | '[')) {
551            return format!("{base}{unit}");
552        }
553
554        // HTML-family opening-tag bump: `<head>` / `<div class="...">`.
555        // Gated on filetype so Rust generics like `Vec<T>` don't trigger.
556        // Reuses scan_tag_opener which already filters self-closing and
557        // void elements.
558        if is_html_filetype(&settings.filetype) {
559            let trimmed_end_len = prev_line
560                .trim_end_matches(|c: char| c.is_whitespace())
561                .len();
562            let trimmed = &prev_line[..trimmed_end_len];
563            if let Some(stripped) = trimmed.strip_suffix('>')
564                && scan_tag_opener(trimmed, stripped.len()).is_some()
565            {
566                return format!("{base}{unit}");
567            }
568        }
569    }
570
571    base
572}
573
574// ── Comment-continuation helpers ──────────────────────────────────────────
575
576/// Return the ordered (longest-first) list of line-comment prefixes for
577/// `lang`. Each prefix includes one trailing space (e.g. `"// "`).
578/// The same table lives in `hjkl-lang::comment` for the `gc` toggle (#187).
579fn comment_prefixes_for_lang(lang: &str) -> &'static [&'static str] {
580    match lang {
581        "rust" => &["/// ", "//! ", "// "],
582        "c" | "cpp" => &["// "],
583        "python" | "sh" | "bash" | "zsh" | "fish" | "toml" | "yaml" => &["# "],
584        "lua" => &["-- "],
585        "sql" => &["-- "],
586        "vim" | "viml" => &["\" "],
587        _ => &[],
588    }
589}
590
591/// Detect whether `line` starts with a known comment prefix for `lang`.
592///
593/// Returns `Some((indent, prefix))` where `indent` is the leading whitespace
594/// of the line and `prefix` is the canonical (with trailing space) comment
595/// marker. Returns `None` when the line is not a recognised comment.
596pub(crate) fn detect_comment_on_line(lang: &str, line: &str) -> Option<(String, &'static str)> {
597    let indent_end = line
598        .char_indices()
599        .find(|(_, c)| *c != ' ' && *c != '\t')
600        .map(|(i, _)| i)
601        .unwrap_or(line.len());
602    let indent = line[..indent_end].to_string();
603    let rest = &line[indent_end..];
604    for &prefix in comment_prefixes_for_lang(lang) {
605        if rest.starts_with(prefix) {
606            return Some((indent, prefix));
607        }
608        // Also match the bare prefix (line that is exactly `//` with no
609        // trailing content).
610        let bare = prefix.trim_end_matches(' ');
611        if rest == bare || rest.starts_with(&format!("{bare} ")) {
612            return Some((indent, prefix));
613        }
614    }
615    None
616}
617
618/// Given the current `row` in `buffer` and the active `settings`, return the
619/// string to prepend on the new line when comment-continuation fires.
620///
621/// Returns `Some("<indent><prefix>")` when the row is a comment line and
622/// continuation is appropriate, `None` otherwise. The caller appends the
623/// string after the `\n` they are about to insert.
624pub(crate) fn continue_comment(
625    buffer: &hjkl_buffer::Buffer,
626    settings: &crate::editor::Settings,
627    row: usize,
628) -> Option<String> {
629    if settings.filetype.is_empty() {
630        return None;
631    }
632    let line = crate::buf_helpers::buf_line(buffer, row)?;
633    let (indent, prefix) = detect_comment_on_line(&settings.filetype, &line)?;
634    Some(format!("{indent}{prefix}"))
635}
636
637/// Strip one indent unit from the beginning of `line` and insert `ch`
638/// instead. Returns `true` when it consumed the keystroke (dedent +
639/// insert), `false` when the caller should insert normally.
640///
641/// Dedent fires when:
642///   - `smartindent` is on
643///   - `ch` is `}` / `)` / `]`
644///   - all bytes BEFORE the cursor on the current line are whitespace
645///   - there is at least one full indent unit of leading whitespace
646fn try_dedent_close_bracket<H: crate::types::Host>(
647    ed: &mut Editor<hjkl_buffer::Buffer, H>,
648    cursor: hjkl_buffer::Position,
649    ch: char,
650) -> bool {
651    use hjkl_buffer::{Edit, MotionKind, Position};
652
653    if !ed.settings.smartindent {
654        return false;
655    }
656    if !matches!(ch, '}' | ')' | ']') {
657        return false;
658    }
659
660    let line = match buf_line(&ed.buffer, cursor.row) {
661        Some(l) => l.to_string(),
662        None => return false,
663    };
664
665    // All chars before cursor must be whitespace.
666    let before: String = line.chars().take(cursor.col).collect();
667    if !before.chars().all(|c| c == ' ' || c == '\t') {
668        return false;
669    }
670    if before.is_empty() {
671        // Nothing to strip — just insert normally (cursor at col 0).
672        return false;
673    }
674
675    // Compute indent unit.
676    let unit_len: usize = if ed.settings.expandtab {
677        if ed.settings.softtabstop > 0 {
678            ed.settings.softtabstop
679        } else {
680            ed.settings.shiftwidth
681        }
682    } else {
683        // Tab: one literal tab character.
684        1
685    };
686
687    // Check there's at least one full unit to strip.
688    let strip_len = if ed.settings.expandtab {
689        // Count leading spaces; need at least `unit_len`.
690        let spaces = before.chars().filter(|c| *c == ' ').count();
691        if spaces < unit_len {
692            return false;
693        }
694        unit_len
695    } else {
696        // noexpandtab: strip one leading tab.
697        if !before.starts_with('\t') {
698            return false;
699        }
700        1
701    };
702
703    // Delete the leading `strip_len` chars of the current line.
704    ed.mutate_edit(Edit::DeleteRange {
705        start: Position::new(cursor.row, 0),
706        end: Position::new(cursor.row, strip_len),
707        kind: MotionKind::Char,
708    });
709    // Insert the close bracket at column 0 (after the delete the cursor
710    // is still positioned at the end of the remaining whitespace; the
711    // delete moved the text so the cursor is now at col = before.len() -
712    // strip_len).
713    let new_col = cursor.col.saturating_sub(strip_len);
714    ed.mutate_edit(Edit::InsertChar {
715        at: Position::new(cursor.row, new_col),
716        ch,
717    });
718    true
719}
720
721fn finish_insert_session<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
722    let Some(session) = ed.vim.insert_session.take() else {
723        return;
724    };
725    let after_rope = crate::types::Query::rope(&ed.buffer);
726    // Clamp both slices to their respective bounds — the buffer may have
727    // grown (Enter splits rows) or shrunk (Backspace joins rows) during
728    // the session, so row_max can overshoot either side.
729    let before_n = session.before_rope.len_lines();
730    let after_n = after_rope.len_lines();
731    let after_end = session.row_max.min(after_n.saturating_sub(1));
732    let before_end = session.row_max.min(before_n.saturating_sub(1));
733    let before = if before_end >= session.row_min && session.row_min < before_n {
734        rope_row_range_str(&session.before_rope, session.row_min, before_end)
735    } else {
736        String::new()
737    };
738    let after = if after_end >= session.row_min && session.row_min < after_n {
739        rope_row_range_str(&after_rope, session.row_min, after_end)
740    } else {
741        String::new()
742    };
743    // `R` overstrike keeps the line length the same, so `extract_inserted`
744    // (which only reports net growth) misses the typed text. Use the changed
745    // run instead so dot-repeat retypes it.
746    let inserted = if matches!(session.reason, InsertReason::Replace) {
747        changed_run(&before, &after)
748    } else {
749        extract_inserted(&before, &after)
750    };
751    // vim `".` register — text of the most recent insert.
752    if !ed.vim.replaying && !inserted.is_empty() {
753        ed.vim.last_insert_text = Some(inserted.clone());
754    }
755    let open_line = matches!(session.reason, InsertReason::Open { .. });
756    if session.count > 1 && !ed.vim.replaying {
757        use hjkl_buffer::{Edit, Position};
758        if open_line {
759            // `[count]o` / `[count]O` open `count` SEPARATE lines, each with the
760            // typed text. Read the just-opened line's content directly (the
761            // row-range extract above is unreliable across the open boundary)
762            // and stack `count - 1` further lines below it.
763            let (start_row, _) = ed.cursor();
764            let typed = buf_line(&ed.buffer, start_row).unwrap_or_default();
765            for at_row in start_row..start_row + (session.count - 1) {
766                let end = buf_line_chars(&ed.buffer, at_row);
767                ed.mutate_edit(Edit::InsertStr {
768                    at: Position::new(at_row, end),
769                    text: format!("\n{typed}"),
770                });
771            }
772        } else if !inserted.is_empty() {
773            // `[count]i` / `[count]A` repeat the typed text inline.
774            for _ in 0..session.count - 1 {
775                let (row, col) = ed.cursor();
776                ed.mutate_edit(Edit::InsertStr {
777                    at: Position::new(row, col),
778                    text: inserted.clone(),
779                });
780            }
781        }
782    }
783    // Helper: replicate `inserted` text across block rows top+1..=bot at `col`,
784    // padding short rows to reach `col` first. Returns without touching the
785    // cursor — callers position the cursor afterward according to their needs.
786    fn replicate_block_text<H: crate::types::Host>(
787        ed: &mut Editor<hjkl_buffer::Buffer, H>,
788        inserted: &str,
789        top: usize,
790        bot: usize,
791        col: usize,
792    ) {
793        use hjkl_buffer::{Edit, Position};
794        for r in (top + 1)..=bot {
795            let line_len = buf_line_chars(&ed.buffer, r);
796            if col > line_len {
797                let pad: String = std::iter::repeat_n(' ', col - line_len).collect();
798                ed.mutate_edit(Edit::InsertStr {
799                    at: Position::new(r, line_len),
800                    text: pad,
801                });
802            }
803            ed.mutate_edit(Edit::InsertStr {
804                at: Position::new(r, col),
805                text: inserted.to_string(),
806            });
807        }
808    }
809
810    if let InsertReason::BlockEdge { top, bot, col } = session.reason {
811        // `I` / `A` from VisualBlock: replicate text across rows; cursor
812        // stays at the block-start column (vim leaves cursor there).
813        if !inserted.is_empty() && top < bot && !ed.vim.replaying {
814            replicate_block_text(ed, &inserted, top, bot, col);
815            buf_set_cursor_rc(&mut ed.buffer, top, col);
816            ed.push_buffer_cursor_to_textarea();
817        }
818        return;
819    }
820    if let InsertReason::BlockChange { top, bot, col } = session.reason {
821        // `c` from VisualBlock: replicate text across rows; cursor advances
822        // to `col + ins_chars` (pre-step-back) so the Esc step-back lands
823        // on the last typed char (col + ins_chars - 1), matching nvim.
824        if !inserted.is_empty() && top < bot && !ed.vim.replaying {
825            replicate_block_text(ed, &inserted, top, bot, col);
826            let ins_chars = inserted.chars().count();
827            let line_len = buf_line_chars(&ed.buffer, top);
828            let target_col = (col + ins_chars).min(line_len);
829            buf_set_cursor_rc(&mut ed.buffer, top, target_col);
830            ed.push_buffer_cursor_to_textarea();
831        }
832        return;
833    }
834    if ed.vim.replaying {
835        return;
836    }
837    match session.reason {
838        InsertReason::Enter(entry) => {
839            ed.vim.last_change = Some(LastChange::InsertAt {
840                entry,
841                inserted,
842                count: session.count,
843            });
844        }
845        InsertReason::Open { above } => {
846            ed.vim.last_change = Some(LastChange::OpenLine { above, inserted });
847        }
848        InsertReason::AfterChange => {
849            if let Some(
850                LastChange::OpMotion { inserted: ins, .. }
851                | LastChange::OpTextObj { inserted: ins, .. }
852                | LastChange::LineOp { inserted: ins, .. }
853                | LastChange::GnOp { inserted: ins, .. },
854            ) = ed.vim.last_change.as_mut()
855            {
856                *ins = Some(inserted);
857            }
858            // Vim `:h '[` / `:h ']`: on change, `[` = start of the
859            // changed range (stashed before the cut), `]` = the cursor
860            // at Esc time (last inserted char, before the step-back).
861            // When nothing was typed cursor still sits at the change
862            // start, satisfying vim's "both at start" parity for `c<m><Esc>`.
863            if let Some(start) = ed.vim.change_mark_start.take() {
864                let end = ed.cursor();
865                ed.set_mark('[', start);
866                ed.set_mark(']', end);
867            }
868        }
869        InsertReason::DeleteToEol => {
870            ed.vim.last_change = Some(LastChange::DeleteToEol {
871                inserted: Some(inserted),
872            });
873        }
874        InsertReason::ReplayOnly => {}
875        InsertReason::BlockEdge { .. } => unreachable!("handled above"),
876        InsertReason::BlockChange { .. } => unreachable!("handled above"),
877        InsertReason::Replace => {
878            // `R` overstrike: dot-repeat re-overtypes the same text at the
879            // cursor (vim parity — not a delete-to-EOL).
880            ed.vim.last_change = Some(LastChange::ReplaceMode { text: inserted });
881        }
882    }
883}
884
885pub(crate) fn begin_insert<H: crate::types::Host>(
886    ed: &mut Editor<hjkl_buffer::Buffer, H>,
887    count: usize,
888    reason: InsertReason,
889) {
890    // `nomodifiable`: silently refuse to enter insert/replace; stay in current mode.
891    if !ed.settings.modifiable {
892        return;
893    }
894    // BLAME view: pressing `i` exits blame (drops the overlay) but stays Normal.
895    if ed.view == crate::ViewMode::Blame {
896        ed.view = crate::ViewMode::Normal;
897        return;
898    }
899    let record = !matches!(reason, InsertReason::ReplayOnly);
900    if record {
901        ed.push_undo();
902    }
903    let reason = if ed.vim.replaying {
904        InsertReason::ReplayOnly
905    } else {
906        reason
907    };
908    let (row, col) = ed.cursor();
909    ed.vim.insert_session = Some(InsertSession {
910        count,
911        row_min: row,
912        row_max: row,
913        before_rope: crate::types::Query::rope(&ed.buffer),
914        reason,
915        start_row: row,
916        start_col: col,
917    });
918    ed.vim.mode = Mode::Insert;
919    // Phase 6.3: keep current_mode in sync for callers that bypass step().
920    ed.vim.current_mode = crate::VimMode::Insert;
921    drop_blame_if_left_normal(ed);
922}
923
924/// `:set undobreak` semantics for insert-mode motions. When the
925/// toggle is on, a non-character keystroke that moves the cursor
926/// (arrow keys, Home/End, mouse click) ends the current undo group
927/// and starts a new one mid-session. After this, a subsequent `u`
928/// in normal mode reverts only the post-break run, leaving the
929/// pre-break edits in place — matching vim's behaviour.
930///
931/// Implementation: snapshot the current buffer onto the undo stack
932/// (the new break point) and reset the active `InsertSession`'s
933/// `before_lines` so `finish_insert_session`'s diff window only
934/// captures the post-break run for `last_change` / dot-repeat.
935///
936/// During replay we skip the break — replay shouldn't pollute the
937/// undo stack with intra-replay snapshots.
938pub(crate) fn break_undo_group_in_insert<H: crate::types::Host>(
939    ed: &mut Editor<hjkl_buffer::Buffer, H>,
940) {
941    if !ed.settings.undo_break_on_motion {
942        return;
943    }
944    if ed.vim.replaying {
945        return;
946    }
947    if ed.vim.insert_session.is_none() {
948        return;
949    }
950    ed.push_undo();
951    let before_rope = crate::types::Query::rope(&ed.buffer);
952    let row = crate::types::Cursor::cursor(&ed.buffer).line as usize;
953    if let Some(ref mut session) = ed.vim.insert_session {
954        session.before_rope = before_rope;
955        session.row_min = row;
956        session.row_max = row;
957    }
958}
959
960/// Word-boundary undo break for [`crate::editor::UndoGranularity::Word`].
961///
962/// Called from [`insert_char_bridge`] (before inserting `next`) and from
963/// [`insert_newline_bridge`] (pass `next = '\n'`).
964///
965/// **Heuristic:** a break is inserted when:
966/// - `next` is a non-whitespace char **and** the char immediately before
967///   the cursor is whitespace (or the cursor is at column 0 but not the
968///   session-start position — i.e. the user has already typed something
969///   and then navigated or wrapped to column 0). This corresponds to
970///   "the first character of a new word just after whitespace."
971/// - `next` is `'\n'` (newline always starts a new undo unit).
972///
973/// **No break on the session's very first char:** `begin_insert` already
974/// pushed an undo snapshot; breaking again would create an empty entry.
975/// We detect "first char" by comparing the current cursor position to the
976/// session's `(start_row, start_col)`.
977///
978/// During replay (`ed.vim.replaying`) or when there is no active insert
979/// session, this is a complete no-op — the vim path is unchanged.
980///
981/// When `undo_granularity == InsertSession` this function returns
982/// immediately, adding zero calls to the hot path.
983pub(crate) fn maybe_word_undo_break<H: crate::types::Host>(
984    ed: &mut Editor<hjkl_buffer::Buffer, H>,
985    next: char,
986) {
987    use crate::buf_helpers::{buf_cursor_pos, buf_line};
988    use crate::editor::UndoGranularity;
989
990    // Fast-path: default (vim) granularity → no-op.
991    if ed.settings.undo_granularity != UndoGranularity::Word {
992        return;
993    }
994    // No-op during replay or when there is no active insert session.
995    if ed.vim.replaying {
996        return;
997    }
998    let session = match ed.vim.insert_session.as_ref() {
999        Some(s) => s,
1000        None => return,
1001    };
1002
1003    let cursor = buf_cursor_pos(&ed.buffer);
1004
1005    // Skip the very first inserted char (begin_insert already snapshotted).
1006    let is_first_pos = cursor.row == session.start_row && cursor.col == session.start_col;
1007    if is_first_pos {
1008        return;
1009    }
1010
1011    // Newline always breaks.
1012    let should_break = if next == '\n' {
1013        true
1014    } else if next.is_whitespace() {
1015        // Whitespace chars do not start a new word.
1016        false
1017    } else {
1018        // Non-whitespace: break only when the char immediately before the
1019        // cursor is whitespace (entering a word from whitespace territory).
1020        let prev_char = buf_line(&ed.buffer, cursor.row)
1021            .as_deref()
1022            .and_then(|line| line.chars().nth(cursor.col.wrapping_sub(1)));
1023        match prev_char {
1024            // Previous char is whitespace → this is a word start.
1025            Some(p) if p.is_whitespace() => true,
1026            // cursor.col == 0 means we arrived here from another line:
1027            // that too is a word-start boundary (newline already handled
1028            // above for the \n itself; this handles the first char on the
1029            // new line after the newline was inserted as a prior break).
1030            None if cursor.col == 0 => false, // col-0 on first position covered by start check above
1031            _ => false,
1032        }
1033    };
1034
1035    if should_break {
1036        // Reuse the existing mid-session break machinery: push a snapshot
1037        // and reset session.before_rope + row_min/row_max.
1038        ed.push_undo();
1039        let before_rope = crate::types::Query::rope(&ed.buffer);
1040        let row = cursor.row;
1041        if let Some(ref mut session) = ed.vim.insert_session {
1042            session.before_rope = before_rope;
1043            session.row_min = row;
1044            session.row_max = row;
1045        }
1046    }
1047}
1048
1049// ─── Phase 6.1: public insert-mode primitives ──────────────────────────────
1050//
1051// Each `pub(crate)` free function below implements one insert-mode action.
1052// hjkl-vim's insert dispatcher calls them through `Editor::insert_*` methods.
1053// External callers can also invoke the public Editor methods directly.
1054//
1055// Invariants every function upholds:
1056//   - Opens with `ed.sync_buffer_content_from_textarea()` (no-op, kept for
1057//     forward compatibility once textarea is gone).
1058//   - All buffer mutations go through `ed.mutate_edit(...)` so dirty flag,
1059//     undo, change-list, content-edit fan-out all fire uniformly.
1060//   - Navigation-only functions call `break_undo_group_in_insert` when the
1061//     FSM did so, then return `false` (no mutation).
1062//   - After mutations, `ed.push_buffer_cursor_to_textarea()` is called
1063//     (currently a no-op but kept for migration hygiene).
1064//   - Returns `true` when the buffer was mutated, `false` otherwise.
1065
1066/// Return the filetype-gated autopair close character for `open`, or `None`
1067/// when no pairing applies.
1068///
1069/// Rules:
1070/// - `(` → `)`, `[` → `]`, `{` → `}` always.
1071/// - `"` → `"` and `` ` `` → `` ` `` always, EXCEPT when the previous two
1072///   characters are the same quote — typing the third `` ` `` of a markdown
1073///   code-fence or the third `"` of a Python triple-quoted string must
1074///   emit a bare quote (no close) so the result is `` ``` `` / `"""` and
1075///   not `` ```` `` / `""""`.
1076/// - `<` → `>` only for HTML/XML family filetypes.
1077/// - `'` → `'` unless the character immediately before the cursor is
1078///   `[A-Za-z]` (prose apostrophe guard — "don't" stays "don't"), AND the
1079///   same triple-quote guard as `"` / `` ` ``.
1080fn autopair_close_for(
1081    ch: char,
1082    filetype: &str,
1083    prev_char: Option<char>,
1084    prev2_char: Option<char>,
1085) -> Option<char> {
1086    // Triple-quote guard — applies to ", `, and ' (the three quote chars
1087    // that get same-char pairing). When the previous two characters are
1088    // both this same quote, treat the third keystroke as a bare insert so
1089    // the user lands on `` ``` `` / `"""` / `'''` without a stray fourth
1090    // quote dangling after the cursor.
1091    let is_triple_quote_third =
1092        matches!(ch, '"' | '`' | '\'') && prev_char == Some(ch) && prev2_char == Some(ch);
1093
1094    match ch {
1095        '(' => Some(')'),
1096        '[' => Some(']'),
1097        '{' => Some('}'),
1098        '"' => {
1099            if is_triple_quote_third {
1100                None
1101            } else {
1102                Some('"')
1103            }
1104        }
1105        '`' => {
1106            if is_triple_quote_third {
1107                None
1108            } else {
1109                Some('`')
1110            }
1111        }
1112        '<' => {
1113            if is_html_filetype(filetype) {
1114                Some('>')
1115            } else {
1116                None
1117            }
1118        }
1119        '\'' => {
1120            if is_triple_quote_third {
1121                return None;
1122            }
1123            // Prose guard: skip pairing when the previous char is a letter
1124            // (covers "don't", "it's", etc.).
1125            if prev_char.map(|c| c.is_ascii_alphabetic()).unwrap_or(false) {
1126                None
1127            } else {
1128                Some('\'')
1129            }
1130        }
1131        _ => None,
1132    }
1133}
1134
1135/// Detect a markdown / doc-comment code-fence opener on the current line.
1136///
1137/// Returns `Some(fence)` (the backtick run that should be used as the
1138/// closing fence) when:
1139/// - The cursor is at the end of the visible line (`cursor_col` equals the
1140///   line's char count).
1141/// - The line, after leading whitespace, begins with 3+ backticks followed
1142///   by a non-empty language tag matching `[A-Za-z0-9_+-]+` and nothing
1143///   else (no trailing space, no extra text).
1144///
1145/// The language tag requirement is deliberate: a bare ` ``` ` could be
1146/// either an opener OR a closer, and we don't track fence parity here.
1147/// Requiring a tag means we only fire when the user is clearly opening a
1148/// fence (` ```rust `, ` ```ts `, etc.).
1149fn detect_code_fence_opener(line: &str, cursor_col: usize) -> Option<String> {
1150    if cursor_col != line.chars().count() {
1151        return None;
1152    }
1153    let trimmed = line.trim_start();
1154    let backtick_run = trimmed.chars().take_while(|c| *c == '`').count();
1155    if backtick_run < 3 {
1156        return None;
1157    }
1158    let rest = &trimmed[backtick_run..];
1159    if rest.is_empty() {
1160        return None;
1161    }
1162    let all_lang_chars = rest
1163        .chars()
1164        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '+' || c == '-');
1165    if !all_lang_chars {
1166        return None;
1167    }
1168    Some("`".repeat(backtick_run))
1169}
1170
1171/// Filetypes that get HTML/XML-family treatment (`<` pairing + tag autoclose).
1172fn is_html_filetype(ft: &str) -> bool {
1173    matches!(
1174        ft,
1175        "html" | "xml" | "svg" | "jsx" | "tsx" | "vue" | "svelte"
1176    )
1177}
1178
1179// ── Paired-tag auto-rename (issue #182) ────────────────────────────────────
1180//
1181// When the user edits the name of an HTML/XML opening tag (e.g. `ci<` to
1182// change-inner the tag name, type a new name, then `<Esc>`), the matching
1183// closing tag should rename automatically so the pair stays in sync.
1184// Same on the close side: edit `</X>` → its opener gets renamed.
1185//
1186// Trigger: leave_insert_to_normal_bridge calls sync_paired_tag_on_exit, which
1187// inspects the cursor's current position. If the cursor sits inside a tag
1188// name and the paired tag has a different name, rewrite the paired tag.
1189//
1190// Pairing uses a stack-based scan so nested same-name tags
1191// (`<div><div></div></div>`) pair correctly.
1192
1193/// Tag kind detected at a cursor position.
1194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1195enum TagKind {
1196    Open,
1197    Close,
1198}
1199
1200/// A single tag instance located in the buffer.
1201#[derive(Debug, Clone, PartialEq, Eq)]
1202struct TagSpan {
1203    kind: TagKind,
1204    name: String,
1205    /// Row index in the buffer.
1206    row: usize,
1207    /// Char-column range of the tag NAME (excluding `<`, `</`, attributes, `>`).
1208    name_start_col: usize,
1209    name_end_col: usize,
1210}
1211
1212/// Detect the tag containing `(row, col)` in `line`. Returns the tag kind
1213/// (Open / Close), its name, and the char-column range of that name.
1214/// Returns `None` when the cursor is not inside a tag-name region.
1215fn detect_tag_at_cursor(line: &str, row: usize, col: usize) -> Option<TagSpan> {
1216    let chars: Vec<char> = line.chars().collect();
1217    // Find the nearest `<` at or before the cursor column.
1218    let mut lt = None;
1219    let mut i = col.min(chars.len());
1220    while i > 0 {
1221        i -= 1;
1222        let c = chars[i];
1223        if c == '<' {
1224            lt = Some(i);
1225            break;
1226        }
1227        // Bail if we cross a `>` (we're outside any open tag).
1228        if c == '>' {
1229            return None;
1230        }
1231    }
1232    let lt = lt?;
1233    // Detect close tag (`</`) vs open (`<`).
1234    let (kind, name_start) = if chars.get(lt + 1) == Some(&'/') {
1235        (TagKind::Close, lt + 2)
1236    } else {
1237        (TagKind::Open, lt + 1)
1238    };
1239    // First char of the name must be a letter.
1240    let first = chars.get(name_start)?;
1241    if !first.is_ascii_alphabetic() {
1242        return None;
1243    }
1244    // Tag name = [A-Za-z][A-Za-z0-9-]*
1245    let mut name_end = name_start;
1246    while name_end < chars.len()
1247        && (chars[name_end].is_ascii_alphanumeric() || chars[name_end] == '-')
1248    {
1249        name_end += 1;
1250    }
1251    // Cursor must be inside the name range (inclusive of both ends so that
1252    // landing right after the name still resolves — vim Insert leaves the
1253    // cursor one past the last typed char).
1254    if col < name_start || col > name_end {
1255        return None;
1256    }
1257    let name: String = chars[name_start..name_end].iter().collect();
1258    Some(TagSpan {
1259        kind,
1260        name,
1261        row,
1262        name_start_col: name_start,
1263        name_end_col: name_end,
1264    })
1265}
1266
1267/// Scan the buffer to find the structural partner of `anchor` using a
1268/// depth counter. Names are intentionally NOT compared during the scan —
1269/// the anchor is the source of truth and the partner inherits its name.
1270/// Otherwise an in-flight rename (the whole point of this feature) would
1271/// look like a malformed pair and bail.
1272///
1273/// Forward scan from an opener: opens increment depth, closes decrement
1274/// depth. The close that brings depth back to zero is the partner.
1275/// Backward scan from a closer is symmetric (closes increment, opens
1276/// decrement).
1277///
1278/// Returns `None` when the buffer end is reached before depth hits zero
1279/// (orphan tag or malformed input).
1280fn find_matching_tag(buffer: &hjkl_buffer::Buffer, anchor: &TagSpan) -> Option<TagSpan> {
1281    let row_count = buffer.row_count();
1282    let scan_forward = anchor.kind == TagKind::Open;
1283    let row_iter: Box<dyn Iterator<Item = usize>> = if scan_forward {
1284        Box::new(anchor.row..row_count)
1285    } else {
1286        Box::new((0..=anchor.row).rev())
1287    };
1288    let push_kind = if scan_forward {
1289        TagKind::Open
1290    } else {
1291        TagKind::Close
1292    };
1293    let mut depth: usize = 1;
1294
1295    for r in row_iter {
1296        let line = buf_line(buffer, r)?;
1297        let chars: Vec<char> = line.chars().collect();
1298        let tags = scan_line_tags(&chars, r);
1299        let tags_iter: Box<dyn Iterator<Item = TagSpan>> = if scan_forward {
1300            Box::new(tags.into_iter())
1301        } else {
1302            Box::new(tags.into_iter().rev())
1303        };
1304        for tag in tags_iter {
1305            // Skip the anchor itself when we walk over its line.
1306            if r == anchor.row
1307                && tag.name_start_col == anchor.name_start_col
1308                && tag.kind == anchor.kind
1309            {
1310                continue;
1311            }
1312            // On the anchor's own row, gate by direction relative to anchor
1313            // so the scan only inspects tags AFTER the anchor (forward) or
1314            // BEFORE the anchor (backward).
1315            if r == anchor.row {
1316                if scan_forward && tag.name_start_col < anchor.name_start_col {
1317                    continue;
1318                }
1319                if !scan_forward && tag.name_start_col > anchor.name_start_col {
1320                    continue;
1321                }
1322            }
1323            if tag.kind == push_kind {
1324                depth += 1;
1325            } else {
1326                depth -= 1;
1327                if depth == 0 {
1328                    return Some(tag);
1329                }
1330            }
1331        }
1332    }
1333    None
1334}
1335
1336/// Collect all tag opens / closes on a single line in left-to-right order.
1337/// Skips comments (`<!-- ... -->`) and self-closing tags (`<br />`), and
1338/// excludes void HTML elements that don't form a pair.
1339fn scan_line_tags(chars: &[char], row: usize) -> Vec<TagSpan> {
1340    let mut out = Vec::new();
1341    let n = chars.len();
1342    let mut i = 0;
1343    while i < n {
1344        if chars[i] != '<' {
1345            i += 1;
1346            continue;
1347        }
1348        // `<!--` comment — skip to `-->`.
1349        if chars[i..].starts_with(&['<', '!', '-', '-']) {
1350            let mut j = i + 4;
1351            while j + 2 < n && !(chars[j] == '-' && chars[j + 1] == '-' && chars[j + 2] == '>') {
1352                j += 1;
1353            }
1354            i = (j + 3).min(n);
1355            continue;
1356        }
1357        let (kind, name_start) = if chars.get(i + 1) == Some(&'/') {
1358            (TagKind::Close, i + 2)
1359        } else {
1360            (TagKind::Open, i + 1)
1361        };
1362        // Validate name start.
1363        if chars
1364            .get(name_start)
1365            .is_none_or(|c| !c.is_ascii_alphabetic())
1366        {
1367            i += 1;
1368            continue;
1369        }
1370        let mut name_end = name_start;
1371        while name_end < n && (chars[name_end].is_ascii_alphanumeric() || chars[name_end] == '-') {
1372            name_end += 1;
1373        }
1374        // Find the closing `>` to know whether this tag is self-closing.
1375        let mut k = name_end;
1376        let mut self_closing = false;
1377        while k < n {
1378            if chars[k] == '>' {
1379                if k > name_end && chars[k - 1] == '/' {
1380                    self_closing = true;
1381                }
1382                break;
1383            }
1384            k += 1;
1385        }
1386        if k >= n {
1387            // Unterminated tag on this line — bail.
1388            break;
1389        }
1390        let name: String = chars[name_start..name_end].iter().collect();
1391        // Skip self-closing and void elements (no pair).
1392        if !(self_closing || kind == TagKind::Open && is_void_element(&name)) {
1393            out.push(TagSpan {
1394                kind,
1395                name,
1396                row,
1397                name_start_col: name_start,
1398                name_end_col: name_end,
1399            });
1400        }
1401        i = k + 1;
1402    }
1403    out
1404}
1405
1406/// If the cursor sits inside an HTML/XML tag name AND the paired tag's name
1407/// differs, rewrite the paired tag's name to match. Called from
1408/// `leave_insert_to_normal_bridge` so the magical sync fires exactly when
1409/// the user finishes editing.
1410pub(crate) fn sync_paired_tag_on_exit<H: crate::types::Host>(
1411    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1412) {
1413    if !is_html_filetype(&ed.settings.filetype) {
1414        return;
1415    }
1416    let (row, col) = ed.cursor();
1417    let line = match buf_line(&ed.buffer, row) {
1418        Some(l) => l,
1419        None => return,
1420    };
1421    let anchor = match detect_tag_at_cursor(&line, row, col) {
1422        Some(t) => t,
1423        None => return,
1424    };
1425    let partner = match find_matching_tag(&ed.buffer, &anchor) {
1426        Some(t) => t,
1427        None => return,
1428    };
1429    if partner.name == anchor.name {
1430        return;
1431    }
1432    // Rewrite the partner's name range with the anchor's name.
1433    use hjkl_buffer::{Edit, MotionKind, Position};
1434    let start = Position::new(partner.row, partner.name_start_col);
1435    let end = Position::new(partner.row, partner.name_end_col);
1436    ed.mutate_edit(Edit::DeleteRange {
1437        start,
1438        end,
1439        kind: MotionKind::Char,
1440    });
1441    ed.mutate_edit(Edit::InsertStr {
1442        at: start,
1443        text: anchor.name.clone(),
1444    });
1445    // Restore the user's cursor — mutate_edit may have moved it during the
1446    // partner-side rewrite when the partner is on a row before the cursor.
1447    buf_set_cursor_rc(&mut ed.buffer, row, col);
1448    ed.push_buffer_cursor_to_textarea();
1449}
1450
1451/// Resolve the HTML/XML tag-name pair under the cursor for matchparen-style
1452/// highlight (#243). Returns `[(row, name_start_col, name_end_col); 2]` for
1453/// the tag under the cursor and its structural partner, or `None` when the
1454/// cursor is not on a tag name or the tag is unpaired. Char-column ranges
1455/// (display), consistent with `motions::matching_bracket_pos`.
1456pub fn matching_tag_pair(
1457    buffer: &hjkl_buffer::Buffer,
1458    row: usize,
1459    col: usize,
1460) -> Option<[(usize, usize, usize); 2]> {
1461    let line = buf_line(buffer, row)?;
1462    let anchor = detect_tag_at_cursor(&line, row, col)?;
1463    let partner = find_matching_tag(buffer, &anchor)?;
1464    Some([
1465        (anchor.row, anchor.name_start_col, anchor.name_end_col),
1466        (partner.row, partner.name_start_col, partner.name_end_col),
1467    ])
1468}
1469
1470/// Void HTML elements that must never get an auto-close tag.
1471fn is_void_element(tag: &str) -> bool {
1472    matches!(
1473        tag.to_ascii_lowercase().as_str(),
1474        "area"
1475            | "base"
1476            | "br"
1477            | "col"
1478            | "embed"
1479            | "hr"
1480            | "img"
1481            | "input"
1482            | "link"
1483            | "meta"
1484            | "param"
1485            | "source"
1486            | "track"
1487            | "wbr"
1488    )
1489}
1490
1491/// Scan backward from `col` (exclusive) in `line` for a `<tagname…` opener.
1492///
1493/// Returns `Some(tag_name)` when:
1494/// - An opening `<` is found
1495/// - The tag name matches `[A-Za-z][A-Za-z0-9-]*`
1496/// - The tag is not self-closing (does not end with `/` before `>`)
1497/// - The tag is not a void element
1498///
1499/// Returns `None` otherwise (no opener, self-closing, void, or malformed).
1500fn scan_tag_opener(line: &str, col: usize) -> Option<String> {
1501    // col is where `>` was just inserted (the char is already in the line).
1502    // We look at the slice BEFORE the `>`.
1503    let before = if col > 0 { &line[..col] } else { return None };
1504
1505    // Walk backward to find the matching `<`.
1506    let lt_pos = before.rfind('<')?;
1507    let inner = &before[lt_pos + 1..]; // e.g. "div class=\"foo\""
1508
1509    // A `!` opener is a comment/doctype — skip.
1510    if inner.starts_with('!') {
1511        return None;
1512    }
1513    // Self-closing if the last non-space char before `>` was `/`.
1514    if inner.trim_end().ends_with('/') {
1515        return None;
1516    }
1517
1518    // Extract tag name: first token of `inner`.
1519    let tag: String = inner
1520        .chars()
1521        .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
1522        .collect();
1523    if tag.is_empty() {
1524        return None;
1525    }
1526    // First char must be a letter.
1527    if !tag
1528        .chars()
1529        .next()
1530        .map(|c| c.is_ascii_alphabetic())
1531        .unwrap_or(false)
1532    {
1533        return None;
1534    }
1535    if is_void_element(&tag) {
1536        return None;
1537    }
1538    Some(tag)
1539}
1540
1541/// Insert a single character at the cursor. Handles replace-mode overstrike
1542/// (when `InsertSession::reason` is `Replace`) and smart-indent dedent of
1543/// closing brackets (}/)]/). Also handles autopair insertion and skip-over.
1544/// Returns `true`.
1545pub(crate) fn insert_char_bridge<H: crate::types::Host>(
1546    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1547    ch: char,
1548) -> bool {
1549    use hjkl_buffer::{Edit, MotionKind, Position};
1550    ed.sync_buffer_content_from_textarea();
1551    let in_replace = matches!(
1552        ed.vim.insert_session.as_ref().map(|s| &s.reason),
1553        Some(InsertReason::Replace)
1554    );
1555
1556    // ── Abbreviation expansion (insert mode, non-replace) ────────────────────
1557    // A non-keyword char typed in insert mode can trigger expansion.
1558    // We check BEFORE inserting the character; if an abbrev matches, we delete
1559    // the lhs and insert the rhs, then continue to insert `ch` as normal.
1560    // `<C-v>` (literal-insert) must bypass this — callers that want literal
1561    // insertion should NOT call this bridge; they use insert_char_literal.
1562    if !in_replace && !ed.vim.abbrevs.is_empty() {
1563        let iskeyword = ed.settings.iskeyword.clone();
1564        if !is_keyword_char(ch, &iskeyword) {
1565            // Only non-keyword trigger chars fire abbreviation expansion.
1566            check_and_apply_abbrev(ed, AbbrevTrigger::NonKeyword(ch));
1567            // (we do NOT return early; continue to insert `ch` below)
1568        }
1569    }
1570    // ── Word-boundary undo break (Word granularity only; no-op for vim) ────────
1571    // Must fire after abbreviation expansion (the expansion may have changed the
1572    // cursor position) but before any actual buffer mutation so the snapshot
1573    // captures the pre-char state.
1574    maybe_word_undo_break(ed, ch);
1575
1576    // Read cursor (after any abbreviation expansion that may have changed the buffer).
1577    let cursor = buf_cursor_pos(&ed.buffer);
1578    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
1579
1580    // ── Skip-over: if the typed char matches the top of the pending-closes
1581    // stack AND the char currently under the cursor IS that close char,
1582    // pop the stack and advance the cursor instead of inserting.
1583    //
1584    // We check the actual char in the buffer (not a stored col) so that
1585    // characters typed between the pair don't invalidate the skip — the
1586    // close char shifts right as the user types inside, but the buffer
1587    // char check always finds it correctly.
1588    if !in_replace
1589        && !ed.vim.pending_closes.is_empty()
1590        && let Some(&(pr, _pc, pch)) = ed.vim.pending_closes.last()
1591        && ch == pch
1592        && cursor.row == pr
1593    {
1594        let char_at_cursor =
1595            buf_line(&ed.buffer, cursor.row).and_then(|l| l.chars().nth(cursor.col));
1596        if char_at_cursor == Some(ch) {
1597            ed.vim.pending_closes.pop();
1598            // For `>` skip-over in HTML/XML: also run tag autoclose.
1599            let filetype = ed.settings.filetype.clone();
1600            let autoclose_tag = ed.settings.autoclose_tag;
1601            if ch == '>' && autoclose_tag && is_html_filetype(&filetype) {
1602                // Skip past the `>` that was auto-inserted.
1603                let new_col = cursor.col + 1;
1604                buf_set_cursor_rc(&mut ed.buffer, cursor.row, new_col);
1605                // Now check for tag autoclose on the line up to new_col.
1606                // `new_col.saturating_sub(1)` is a char index; convert to a
1607                // byte offset before slicing in scan_tag_opener.
1608                if let Some(line) = buf_line(&ed.buffer, cursor.row) {
1609                    let char_col = new_col.saturating_sub(1);
1610                    let byte_col = line
1611                        .char_indices()
1612                        .nth(char_col)
1613                        .map(|(b, _)| b)
1614                        .unwrap_or(line.len());
1615                    if let Some(tag) = scan_tag_opener(&line, byte_col) {
1616                        let close_tag = format!("</{tag}>");
1617                        let insert_pos = Position::new(cursor.row, new_col);
1618                        ed.mutate_edit(Edit::InsertStr {
1619                            at: insert_pos,
1620                            text: close_tag,
1621                        });
1622                        // Cursor stays at new_col (between > and </tag>).
1623                        buf_set_cursor_rc(&mut ed.buffer, cursor.row, new_col);
1624                    }
1625                }
1626            } else {
1627                buf_set_cursor_rc(&mut ed.buffer, cursor.row, cursor.col + 1);
1628            }
1629            ed.push_buffer_cursor_to_textarea();
1630            return true;
1631        }
1632    }
1633
1634    if in_replace && cursor.col < line_chars {
1635        // Replace mode: clear pending closes (edit outside the pair).
1636        ed.vim.pending_closes.clear();
1637        ed.mutate_edit(Edit::DeleteRange {
1638            start: cursor,
1639            end: Position::new(cursor.row, cursor.col + 1),
1640            kind: MotionKind::Char,
1641        });
1642        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
1643    } else if !try_dedent_close_bracket(ed, cursor, ch) {
1644        // Normal insert. Check autopair first.
1645        let autopair = ed.settings.autopair;
1646        let filetype = ed.settings.filetype.clone();
1647        let autoclose_tag = ed.settings.autoclose_tag;
1648
1649        let (prev_char, prev2_char) = {
1650            let line = buf_line(&ed.buffer, cursor.row).unwrap_or_default();
1651            let chars: Vec<char> = line.chars().collect();
1652            let p1 = if cursor.col > 0 {
1653                chars.get(cursor.col - 1).copied()
1654            } else {
1655                None
1656            };
1657            let p2 = if cursor.col > 1 {
1658                chars.get(cursor.col - 2).copied()
1659            } else {
1660                None
1661            };
1662            (p1, p2)
1663        };
1664
1665        if autopair {
1666            if let Some(close) = autopair_close_for(ch, &filetype, prev_char, prev2_char) {
1667                // Insert open char.
1668                ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
1669                // Insert close char immediately after the open char.
1670                // After inserting open at cursor, buffer cursor is at cursor.col+1.
1671                let after = Position::new(cursor.row, cursor.col + 1);
1672                ed.mutate_edit(Edit::InsertChar {
1673                    at: after,
1674                    ch: close,
1675                });
1676                // After inserting close, buffer cursor is at cursor.col+2.
1677                // We want cursor between open and close: cursor.col+1.
1678                let between_col = cursor.col + 1;
1679                buf_set_cursor_rc(&mut ed.buffer, cursor.row, between_col);
1680                // Record the close char for skip-over. We store the row and
1681                // the close char; col is not tracked precisely because chars
1682                // typed inside the pair shift the close right. The skip-over
1683                // logic checks the actual buffer char at cursor instead.
1684                ed.vim.pending_closes.push((cursor.row, between_col, close));
1685                ed.push_buffer_cursor_to_textarea();
1686                return true;
1687            }
1688
1689            // Tag autoclose: `>` in HTML/XML family (no prior `<` pair).
1690            // This fires when autopair did NOT match `>` (e.g. `>` was
1691            // typed directly, not via a skip-over of an auto-inserted `>`).
1692            if ch == '>' && autoclose_tag && is_html_filetype(&filetype) {
1693                ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
1694                let new_col = cursor.col + 1;
1695                // scan_tag_opener looks at the line up to (new_col-1), i.e.
1696                // the char just inserted is at index new_col-1.
1697                // `new_col.saturating_sub(1)` is a char index; convert to a
1698                // byte offset before slicing in scan_tag_opener.
1699                if let Some(line) = buf_line(&ed.buffer, cursor.row) {
1700                    let char_col = new_col.saturating_sub(1);
1701                    let byte_col = line
1702                        .char_indices()
1703                        .nth(char_col)
1704                        .map(|(b, _)| b)
1705                        .unwrap_or(line.len());
1706                    if let Some(tag) = scan_tag_opener(&line, byte_col) {
1707                        let close_tag = format!("</{tag}>");
1708                        let insert_pos = Position::new(cursor.row, new_col);
1709                        ed.mutate_edit(Edit::InsertStr {
1710                            at: insert_pos,
1711                            text: close_tag,
1712                        });
1713                        // Cursor stays at new_col (between `>` and `</tag>`).
1714                        buf_set_cursor_rc(&mut ed.buffer, cursor.row, new_col);
1715                    }
1716                }
1717                ed.push_buffer_cursor_to_textarea();
1718                return true;
1719            }
1720        }
1721
1722        // Plain insert — do not clear the pending-closes stack here.
1723        // The stack is cleared on cursor motion or mode change (Esc).
1724        // Clearing here would prevent skip-over from firing after the
1725        // user types content inside an auto-paired bracket.
1726        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
1727    }
1728    ed.push_buffer_cursor_to_textarea();
1729    true
1730}
1731
1732/// Insert a newline at the cursor, applying autoindent / smartindent and
1733/// optionally continuing a line comment when `formatoptions` has `r`.
1734/// Also handles open-pair-newline: Enter between `{|}` / `(|)` / `[|]`
1735/// produces an indented block with the close on its own line.
1736/// Returns `true`.
1737pub(crate) fn insert_newline_bridge<H: crate::types::Host>(
1738    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1739) -> bool {
1740    use hjkl_buffer::Edit;
1741    ed.sync_buffer_content_from_textarea();
1742
1743    // ── Abbreviation expansion on CR ─────────────────────────────────────────
1744    // CR triggers expansion for full-id / end-id / non-id abbreviations.
1745    // We expand BEFORE the newline is inserted; CR is then inserted as normal.
1746    if !ed.vim.abbrevs.is_empty() {
1747        check_and_apply_abbrev(ed, AbbrevTrigger::Cr);
1748    }
1749
1750    // ── Word-boundary undo break for newline (Word granularity only) ────────────
1751    // Newline always starts a new undo unit in Word mode. Fire after
1752    // abbreviation expansion but before any buffer mutation.
1753    maybe_word_undo_break(ed, '\n');
1754
1755    let cursor = buf_cursor_pos(&ed.buffer);
1756    let prev_line = buf_line(&ed.buffer, cursor.row)
1757        .unwrap_or_default()
1758        .to_string();
1759
1760    // Open-pair-newline: if autopair is on and the cursor is between a
1761    // matching open/close bracket pair, split into two newlines so the
1762    // close ends up on its own dedented line.
1763    if ed.settings.autopair && !ed.vim.pending_closes.is_empty() {
1764        // Check: char before cursor is an open bracket AND char at cursor
1765        // is the matching close bracket (from our pending-closes stack).
1766        let prev_char = if cursor.col > 0 {
1767            prev_line.chars().nth(cursor.col - 1)
1768        } else {
1769            None
1770        };
1771        let next_char = prev_line.chars().nth(cursor.col);
1772        let is_open_pair = matches!(
1773            (prev_char, next_char),
1774            (Some('{'), Some('}')) | (Some('('), Some(')')) | (Some('['), Some(']'))
1775        );
1776        if is_open_pair {
1777            // The pending-closes stack refers to the close char at cursor.col.
1778            // We clear it because the newline expansion moves the close.
1779            ed.vim.pending_closes.clear();
1780            // Compute indents: inner gets one extra unit, close gets base.
1781            let base_indent: String = prev_line
1782                .chars()
1783                .take_while(|c| *c == ' ' || *c == '\t')
1784                .collect();
1785            let inner_indent = if ed.settings.expandtab {
1786                let unit = if ed.settings.softtabstop > 0 {
1787                    ed.settings.softtabstop
1788                } else {
1789                    ed.settings.shiftwidth
1790                };
1791                format!("{base_indent}{}", " ".repeat(unit))
1792            } else {
1793                format!("{base_indent}\t")
1794            };
1795            // Insert: \n<inner_indent>\n<base_indent>
1796            // Then cursor lands after the first \n (inside the block).
1797            let text = format!("\n{inner_indent}\n{base_indent}");
1798            ed.mutate_edit(Edit::InsertStr { at: cursor, text });
1799            // Move cursor to end of first new line (inner_indent line).
1800            let new_row = cursor.row + 1;
1801            let new_col = inner_indent.len();
1802            buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
1803            ed.push_buffer_cursor_to_textarea();
1804            return true;
1805        }
1806    }
1807
1808    // Code-fence expansion: line content is ` ``` ` (3+ backticks) followed
1809    // by a non-empty language tag, cursor sits at end of line → insert the
1810    // matching closing fence on the line below and park the cursor on a
1811    // blank middle line. Matches the open-pair-newline shape but for
1812    // markdown / doc-comment code blocks. Gated on a language tag because
1813    // a bare ` ``` ` could just as easily be a closing fence — we'd need
1814    // full document parity tracking to handle that safely, which v1
1815    // doesn't have.
1816    if ed.settings.autopair
1817        && let Some(fence) = detect_code_fence_opener(&prev_line, cursor.col)
1818    {
1819        ed.vim.pending_closes.clear();
1820        let base_indent: String = prev_line
1821            .chars()
1822            .take_while(|c| *c == ' ' || *c == '\t')
1823            .collect();
1824        let text = format!("\n{base_indent}\n{base_indent}{fence}");
1825        ed.mutate_edit(Edit::InsertStr { at: cursor, text });
1826        let new_row = cursor.row + 1;
1827        let new_col = base_indent.chars().count();
1828        buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
1829        ed.push_buffer_cursor_to_textarea();
1830        return true;
1831    }
1832
1833    // formatoptions `r`: continue comment on Enter in insert mode.
1834    let comment_cont = if ed.settings.formatoptions.contains('r') {
1835        continue_comment(&ed.buffer, &ed.settings, cursor.row)
1836    } else {
1837        None
1838    };
1839
1840    // Any Enter clears the pending-closes stack (cursor moved off the pair).
1841    ed.vim.pending_closes.clear();
1842
1843    let text = if let Some(cont) = comment_cont {
1844        // Comment continuation overrides autoindent: the indent is already
1845        // baked into the continuation prefix.
1846        format!("\n{cont}")
1847    } else {
1848        let indent = compute_enter_indent(&ed.settings, &prev_line);
1849        format!("\n{indent}")
1850    };
1851    ed.mutate_edit(Edit::InsertStr { at: cursor, text });
1852    ed.push_buffer_cursor_to_textarea();
1853    true
1854}
1855
1856/// Insert a tab character (or spaces up to the next softtabstop boundary when
1857/// `expandtab` is set). Returns `true`.
1858pub(crate) fn insert_tab_bridge<H: crate::types::Host>(
1859    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1860) -> bool {
1861    use hjkl_buffer::Edit;
1862    ed.sync_buffer_content_from_textarea();
1863    let cursor = buf_cursor_pos(&ed.buffer);
1864    if ed.settings.expandtab {
1865        let sts = ed.settings.softtabstop;
1866        let n = if sts > 0 {
1867            sts - (cursor.col % sts)
1868        } else {
1869            ed.settings.tabstop.max(1)
1870        };
1871        ed.mutate_edit(Edit::InsertStr {
1872            at: cursor,
1873            text: " ".repeat(n),
1874        });
1875    } else {
1876        ed.mutate_edit(Edit::InsertChar {
1877            at: cursor,
1878            ch: '\t',
1879        });
1880    }
1881    ed.push_buffer_cursor_to_textarea();
1882    true
1883}
1884
1885/// Delete the character before the cursor (vim Backspace / `^H`). With
1886/// `softtabstop` active, deletes the entire soft-tab run at an aligned
1887/// boundary. Joins with the previous line when at column 0.
1888///
1889/// **Comment-continuation backspace**: when the current line's entire content
1890/// is the auto-inserted comment prefix (e.g. `// ` with nothing after it),
1891/// a single Backspace removes the whole prefix in one stroke — vim parity.
1892///
1893/// Returns `true` when something was deleted, `false` at the very start of the
1894/// buffer.
1895pub(crate) fn insert_backspace_bridge<H: crate::types::Host>(
1896    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1897) -> bool {
1898    use hjkl_buffer::{Edit, MotionKind, Position};
1899    ed.sync_buffer_content_from_textarea();
1900    let cursor = buf_cursor_pos(&ed.buffer);
1901
1902    // Comment-continuation backspace: if the line is just the prefix (with no
1903    // user content after it), delete the whole prefix in one stroke.
1904    if cursor.col > 0 {
1905        let line = buf_line(&ed.buffer, cursor.row).unwrap_or_default();
1906        if let Some((indent, prefix)) = detect_comment_on_line(&ed.settings.filetype, &line) {
1907            let full_prefix = format!("{indent}{prefix}");
1908            // The cursor must be at the end of (or within) the prefix with no
1909            // additional content after — i.e. the line equals the prefix exactly.
1910            let line_trimmed = line.trim_end_matches(' ');
1911            let prefix_trimmed = full_prefix.trim_end_matches(' ');
1912            if line_trimmed == prefix_trimmed && cursor.col == full_prefix.chars().count() {
1913                // Delete everything from col 0 to cursor.
1914                ed.mutate_edit(Edit::DeleteRange {
1915                    start: Position::new(cursor.row, 0),
1916                    end: cursor,
1917                    kind: MotionKind::Char,
1918                });
1919                ed.push_buffer_cursor_to_textarea();
1920                return true;
1921            }
1922        }
1923    }
1924
1925    let sts = ed.settings.softtabstop;
1926    if sts > 0 && cursor.col >= sts && cursor.col.is_multiple_of(sts) {
1927        let line = buf_line(&ed.buffer, cursor.row).unwrap_or_default();
1928        let chars: Vec<char> = line.chars().collect();
1929        let run_start = cursor.col - sts;
1930        if (run_start..cursor.col).all(|i| chars.get(i).copied() == Some(' ')) {
1931            ed.mutate_edit(Edit::DeleteRange {
1932                start: Position::new(cursor.row, run_start),
1933                end: cursor,
1934                kind: MotionKind::Char,
1935            });
1936            ed.push_buffer_cursor_to_textarea();
1937            return true;
1938        }
1939    }
1940    let result = if cursor.col > 0 {
1941        ed.mutate_edit(Edit::DeleteRange {
1942            start: Position::new(cursor.row, cursor.col - 1),
1943            end: cursor,
1944            kind: MotionKind::Char,
1945        });
1946        true
1947    } else if cursor.row > 0 {
1948        let prev_row = cursor.row - 1;
1949        let prev_chars = buf_line_chars(&ed.buffer, prev_row);
1950        ed.mutate_edit(Edit::JoinLines {
1951            row: prev_row,
1952            count: 1,
1953            with_space: false,
1954        });
1955        buf_set_cursor_rc(&mut ed.buffer, prev_row, prev_chars);
1956        true
1957    } else {
1958        false
1959    };
1960    ed.push_buffer_cursor_to_textarea();
1961    result
1962}
1963
1964/// Delete the character under the cursor (vim `Delete`). Joins with the
1965/// next line when at end-of-line. Returns `true` when something was deleted.
1966pub(crate) fn insert_delete_bridge<H: crate::types::Host>(
1967    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1968) -> bool {
1969    use hjkl_buffer::{Edit, MotionKind, Position};
1970    ed.sync_buffer_content_from_textarea();
1971    let cursor = buf_cursor_pos(&ed.buffer);
1972    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
1973    let result = if cursor.col < line_chars {
1974        ed.mutate_edit(Edit::DeleteRange {
1975            start: cursor,
1976            end: Position::new(cursor.row, cursor.col + 1),
1977            kind: MotionKind::Char,
1978        });
1979        buf_set_cursor_pos(&mut ed.buffer, cursor);
1980        true
1981    } else if cursor.row + 1 < buf_row_count(&ed.buffer) {
1982        ed.mutate_edit(Edit::JoinLines {
1983            row: cursor.row,
1984            count: 1,
1985            with_space: false,
1986        });
1987        buf_set_cursor_pos(&mut ed.buffer, cursor);
1988        true
1989    } else {
1990        false
1991    };
1992    ed.push_buffer_cursor_to_textarea();
1993    result
1994}
1995
1996/// Move the cursor one step in `dir`, breaking the undo group per
1997/// `undo_break_on_motion`. Clears the autopair pending-closes stack (cursor
1998/// moved off the pair). Returns `false` (no mutation).
1999pub(crate) fn insert_arrow_bridge<H: crate::types::Host>(
2000    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2001    dir: InsertDir,
2002) -> bool {
2003    ed.sync_buffer_content_from_textarea();
2004    ed.vim.pending_closes.clear();
2005    match dir {
2006        InsertDir::Left => {
2007            crate::motions::move_left(&mut ed.buffer, 1);
2008        }
2009        InsertDir::Right => {
2010            crate::motions::move_right_to_end(&mut ed.buffer, 1);
2011        }
2012        InsertDir::Up => {
2013            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2014            crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
2015        }
2016        InsertDir::Down => {
2017            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2018            crate::motions::move_down(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
2019        }
2020    }
2021    break_undo_group_in_insert(ed);
2022    ed.push_buffer_cursor_to_textarea();
2023    false
2024}
2025
2026/// Move the cursor to the start of the current line, breaking the undo group.
2027/// Clears the autopair pending-closes stack. Returns `false` (no mutation).
2028pub(crate) fn insert_home_bridge<H: crate::types::Host>(
2029    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2030) -> bool {
2031    ed.sync_buffer_content_from_textarea();
2032    ed.vim.pending_closes.clear();
2033    crate::motions::move_line_start(&mut ed.buffer);
2034    break_undo_group_in_insert(ed);
2035    ed.push_buffer_cursor_to_textarea();
2036    false
2037}
2038
2039/// Move the cursor to the end of the current line, breaking the undo group.
2040/// Clears the autopair pending-closes stack. Returns `false` (no mutation).
2041pub(crate) fn insert_end_bridge<H: crate::types::Host>(
2042    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2043) -> bool {
2044    ed.sync_buffer_content_from_textarea();
2045    ed.vim.pending_closes.clear();
2046    crate::motions::move_line_end(&mut ed.buffer);
2047    break_undo_group_in_insert(ed);
2048    ed.push_buffer_cursor_to_textarea();
2049    false
2050}
2051
2052/// Scroll up one full viewport height, moving the cursor with it.
2053/// Breaks the undo group. Returns `false` (no mutation).
2054pub(crate) fn insert_pageup_bridge<H: crate::types::Host>(
2055    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2056    viewport_h: u16,
2057) -> bool {
2058    let rows = viewport_h.saturating_sub(2).max(1) as isize;
2059    scroll_cursor_rows(ed, -rows);
2060    false
2061}
2062
2063/// Scroll down one full viewport height, moving the cursor with it.
2064/// Breaks the undo group. Returns `false` (no mutation).
2065pub(crate) fn insert_pagedown_bridge<H: crate::types::Host>(
2066    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2067    viewport_h: u16,
2068) -> bool {
2069    let rows = viewport_h.saturating_sub(2).max(1) as isize;
2070    scroll_cursor_rows(ed, rows);
2071    false
2072}
2073
2074/// Delete from the cursor back to the start of the previous word (`Ctrl-W`).
2075/// At col 0, joins with the previous line (vim semantics). Returns `true`
2076/// when something was deleted.
2077pub(crate) fn insert_ctrl_w_bridge<H: crate::types::Host>(
2078    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2079) -> bool {
2080    use hjkl_buffer::{Edit, MotionKind};
2081    ed.sync_buffer_content_from_textarea();
2082    let cursor = buf_cursor_pos(&ed.buffer);
2083    if cursor.row == 0 && cursor.col == 0 {
2084        return true;
2085    }
2086    crate::motions::move_word_back(&mut ed.buffer, false, 1, &ed.settings.iskeyword);
2087    let word_start = buf_cursor_pos(&ed.buffer);
2088    if word_start == cursor {
2089        return true;
2090    }
2091    buf_set_cursor_pos(&mut ed.buffer, cursor);
2092    ed.mutate_edit(Edit::DeleteRange {
2093        start: word_start,
2094        end: cursor,
2095        kind: MotionKind::Char,
2096    });
2097    ed.push_buffer_cursor_to_textarea();
2098    true
2099}
2100
2101/// Delete from the cursor back to the start of the current line (`Ctrl-U`).
2102/// No-op when already at column 0. Returns `true` when something was deleted.
2103pub(crate) fn insert_ctrl_u_bridge<H: crate::types::Host>(
2104    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2105) -> bool {
2106    use hjkl_buffer::{Edit, MotionKind, Position};
2107    ed.sync_buffer_content_from_textarea();
2108    let cursor = buf_cursor_pos(&ed.buffer);
2109    if cursor.col > 0 {
2110        ed.mutate_edit(Edit::DeleteRange {
2111            start: Position::new(cursor.row, 0),
2112            end: cursor,
2113            kind: MotionKind::Char,
2114        });
2115        ed.push_buffer_cursor_to_textarea();
2116    }
2117    true
2118}
2119
2120/// Delete one character backwards (`Ctrl-H`) — alias for Backspace in insert
2121/// mode. Joins with the previous line when at col 0. Returns `true` when
2122/// something was deleted.
2123pub(crate) fn insert_ctrl_h_bridge<H: crate::types::Host>(
2124    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2125) -> bool {
2126    use hjkl_buffer::{Edit, MotionKind, Position};
2127    ed.sync_buffer_content_from_textarea();
2128    let cursor = buf_cursor_pos(&ed.buffer);
2129    if cursor.col > 0 {
2130        ed.mutate_edit(Edit::DeleteRange {
2131            start: Position::new(cursor.row, cursor.col - 1),
2132            end: cursor,
2133            kind: MotionKind::Char,
2134        });
2135    } else if cursor.row > 0 {
2136        let prev_row = cursor.row - 1;
2137        let prev_chars = buf_line_chars(&ed.buffer, prev_row);
2138        ed.mutate_edit(Edit::JoinLines {
2139            row: prev_row,
2140            count: 1,
2141            with_space: false,
2142        });
2143        buf_set_cursor_rc(&mut ed.buffer, prev_row, prev_chars);
2144    }
2145    ed.push_buffer_cursor_to_textarea();
2146    true
2147}
2148
2149/// Indent the current line by one `shiftwidth` and shift the cursor right by
2150/// the same amount (`Ctrl-T`). Returns `true`.
2151pub(crate) fn insert_ctrl_t_bridge<H: crate::types::Host>(
2152    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2153) -> bool {
2154    let (row, col) = ed.cursor();
2155    let sw = ed.settings().shiftwidth;
2156    indent_rows(ed, row, row, 1);
2157    ed.jump_cursor(row, col + sw);
2158    true
2159}
2160
2161/// Outdent the current line by up to one `shiftwidth` and shift the cursor
2162/// left by the amount stripped (`Ctrl-D`). Returns `true`.
2163pub(crate) fn insert_ctrl_d_bridge<H: crate::types::Host>(
2164    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2165) -> bool {
2166    let (row, col) = ed.cursor();
2167    let before_len = buf_line_bytes(&ed.buffer, row);
2168    outdent_rows(ed, row, row, 1);
2169    let after_len = buf_line_bytes(&ed.buffer, row);
2170    let stripped = before_len.saturating_sub(after_len);
2171    let new_col = col.saturating_sub(stripped);
2172    ed.jump_cursor(row, new_col);
2173    true
2174}
2175
2176/// Enter "one-shot normal" mode (`Ctrl-O`): suspend insert for the next
2177/// complete normal-mode command, then return to insert. Returns `false`
2178/// (no buffer mutation — only mode state changes).
2179pub(crate) fn insert_ctrl_o_bridge<H: crate::types::Host>(
2180    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2181) -> bool {
2182    ed.vim.one_shot_normal = true;
2183    ed.vim.mode = Mode::Normal;
2184    // Phase 6.3: keep current_mode in sync for callers that bypass step().
2185    ed.vim.current_mode = crate::VimMode::Normal;
2186    false
2187}
2188
2189/// Arm the register-paste selector (`Ctrl-R`): the next typed character
2190/// names the register whose text will be inserted inline. Returns `false`
2191/// (no buffer mutation yet — mutation happens when the register char arrives).
2192pub(crate) fn insert_ctrl_r_bridge<H: crate::types::Host>(
2193    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2194) -> bool {
2195    ed.vim.insert_pending_register = true;
2196    false
2197}
2198
2199/// Paste the contents of `reg` at the cursor (the body of `Ctrl-R {reg}`).
2200/// Unknown or empty registers are a no-op. Returns `true` when text was
2201/// inserted.
2202pub(crate) fn insert_paste_register_bridge<H: crate::types::Host>(
2203    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2204    reg: char,
2205) -> bool {
2206    insert_register_text(ed, reg);
2207    // insert_register_text already calls mark_content_dirty internally;
2208    // return true to signal that the session row window should be widened.
2209    true
2210}
2211
2212/// Exit insert mode to Normal: finish the insert session, step the cursor one
2213/// cell left (vim convention), record the `gi` target, and update the sticky
2214/// column. Clears the autopair pending-closes stack. Returns `true` (always
2215/// consumed — even if no buffer mutation, the mode change itself is a
2216/// meaningful step).
2217pub(crate) fn leave_insert_to_normal_bridge<H: crate::types::Host>(
2218    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2219) -> bool {
2220    ed.vim.pending_closes.clear();
2221
2222    // ── Abbreviation expansion on Esc ────────────────────────────────────────
2223    // Esc triggers expansion for all abbreviation types.
2224    if !ed.vim.abbrevs.is_empty() {
2225        check_and_apply_abbrev(ed, AbbrevTrigger::Esc);
2226    }
2227
2228    finish_insert_session(ed);
2229    // Paired-tag auto-rename (issue #182). Must run BEFORE the cursor moves
2230    // left (the move-left is vim's "leave-insert cursor adjustment"; the
2231    // sync needs the post-insert cursor position to detect the tag name).
2232    sync_paired_tag_on_exit(ed);
2233    ed.vim.mode = Mode::Normal;
2234    // Phase 6.3: keep current_mode in sync for callers that bypass step().
2235    ed.vim.current_mode = crate::VimMode::Normal;
2236    let col = ed.cursor().1;
2237    ed.vim.last_insert_pos = Some(ed.cursor());
2238    if col > 0 {
2239        crate::motions::move_left(&mut ed.buffer, 1);
2240        ed.push_buffer_cursor_to_textarea();
2241    }
2242    ed.sticky_col = Some(ed.cursor().1);
2243    true
2244}
2245
2246// ─── Phase 6.2: normal-mode primitive bridges ──────────────────────────────
2247
2248// ── Insert-mode entry bridges ──────────────────────────────────────────────
2249
2250/// `i` — begin Insert at the cursor. `count` is stored in the session for
2251/// insert-exit replay. Returns `true`.
2252pub(crate) fn enter_insert_i_bridge<H: crate::types::Host>(
2253    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2254    count: usize,
2255) {
2256    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::I));
2257}
2258
2259/// `I` — move to first non-blank then begin Insert. `count` stored for replay.
2260pub(crate) fn enter_insert_shift_i_bridge<H: crate::types::Host>(
2261    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2262    count: usize,
2263) {
2264    move_first_non_whitespace(ed);
2265    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftI));
2266}
2267
2268/// `a` — advance past the cursor char then begin Insert. `count` for replay.
2269pub(crate) fn enter_insert_a_bridge<H: crate::types::Host>(
2270    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2271    count: usize,
2272) {
2273    crate::motions::move_right_to_end(&mut ed.buffer, 1);
2274    ed.push_buffer_cursor_to_textarea();
2275    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::A));
2276}
2277
2278/// `A` — move to end-of-line then begin Insert. `count` for replay.
2279pub(crate) fn enter_insert_shift_a_bridge<H: crate::types::Host>(
2280    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2281    count: usize,
2282) {
2283    crate::motions::move_line_end(&mut ed.buffer);
2284    crate::motions::move_right_to_end(&mut ed.buffer, 1);
2285    ed.push_buffer_cursor_to_textarea();
2286    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftA));
2287}
2288
2289/// `o` — open a new line below the cursor and begin Insert.
2290/// When `formatoptions` has `o` and the current line is a comment, the
2291/// continuation prefix is inserted automatically.
2292pub(crate) fn open_line_below_bridge<H: crate::types::Host>(
2293    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2294    count: usize,
2295) {
2296    use hjkl_buffer::{Edit, Position};
2297    ed.push_undo();
2298    begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: false });
2299    ed.sync_buffer_content_from_textarea();
2300    let row = buf_cursor_pos(&ed.buffer).row;
2301    let line_chars = buf_line_chars(&ed.buffer, row);
2302    let prev_line = buf_line(&ed.buffer, row).unwrap_or_default();
2303
2304    // formatoptions `o`: continue comment on open-below.
2305    let comment_cont = if ed.settings.formatoptions.contains('o') {
2306        continue_comment(&ed.buffer, &ed.settings, row)
2307    } else {
2308        None
2309    };
2310
2311    let suffix = if let Some(cont) = comment_cont {
2312        format!("\n{cont}")
2313    } else {
2314        let indent = compute_enter_indent(&ed.settings, &prev_line);
2315        format!("\n{indent}")
2316    };
2317    ed.mutate_edit(Edit::InsertStr {
2318        at: Position::new(row, line_chars),
2319        text: suffix,
2320    });
2321    ed.push_buffer_cursor_to_textarea();
2322}
2323
2324/// `O` — open a new line above the cursor and begin Insert.
2325/// When `formatoptions` has `o` and the current line is a comment, the
2326/// continuation prefix is inserted automatically on the new line above.
2327pub(crate) fn open_line_above_bridge<H: crate::types::Host>(
2328    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2329    count: usize,
2330) {
2331    use hjkl_buffer::{Edit, Position};
2332    ed.push_undo();
2333    begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: true });
2334    ed.sync_buffer_content_from_textarea();
2335    let row = buf_cursor_pos(&ed.buffer).row;
2336
2337    // formatoptions `o`: continue comment on open-above (current line drives).
2338    let comment_cont = if ed.settings.formatoptions.contains('o') {
2339        continue_comment(&ed.buffer, &ed.settings, row)
2340    } else {
2341        None
2342    };
2343
2344    // `new_line_content` is the text of the new line (without the trailing `\n`).
2345    // Used to position the cursor at the end of that content after the move.
2346    let (insert_text, new_line_content) = if let Some(cont) = comment_cont {
2347        let content = cont.clone();
2348        (format!("{cont}\n"), content)
2349    } else {
2350        // vim `O` autoindent copies the CURRENT line's indent (the line the
2351        // cursor sits on, which becomes the line *below* the new one), NOT the
2352        // line above. Using the line above wrongly inherits a deeper child's
2353        // indent when the cursor is on a shallower line (e.g. explorer tree:
2354        // `O` on a dir whose preceding row is its own nested child).
2355        let cur = buf_line(&ed.buffer, row).unwrap_or_default();
2356        let indent = compute_enter_indent(&ed.settings, &cur);
2357        let content = indent.clone();
2358        (format!("{indent}\n"), content)
2359    };
2360    ed.mutate_edit(Edit::InsertStr {
2361        at: Position::new(row, 0),
2362        text: insert_text,
2363    });
2364    let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2365    crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
2366    let new_row = buf_cursor_pos(&ed.buffer).row;
2367    buf_set_cursor_rc(&mut ed.buffer, new_row, new_line_content.chars().count());
2368    ed.push_buffer_cursor_to_textarea();
2369}
2370
2371/// `R` — enter Replace mode (overstrike). `count` stored for replay.
2372pub(crate) fn enter_replace_mode_bridge<H: crate::types::Host>(
2373    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2374    count: usize,
2375) {
2376    // Guard delegated to begin_insert which already checks modifiable/Blame.
2377    begin_insert(ed, count.max(1), InsertReason::Replace);
2378}
2379
2380// ── Char / line ops ────────────────────────────────────────────────────────
2381
2382/// `x` — delete `count` chars forward from the cursor, writing to the unnamed
2383/// register. Records `LastChange::CharDel` for dot-repeat.
2384pub(crate) fn delete_char_forward_bridge<H: crate::types::Host>(
2385    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2386    count: usize,
2387) {
2388    do_char_delete(ed, true, count.max(1));
2389    if !ed.vim.replaying {
2390        ed.vim.last_change = Some(LastChange::CharDel {
2391            forward: true,
2392            count: count.max(1),
2393        });
2394    }
2395}
2396
2397/// `X` — delete `count` chars backward from the cursor, writing to the unnamed
2398/// register. Records `LastChange::CharDel` for dot-repeat.
2399pub(crate) fn delete_char_backward_bridge<H: crate::types::Host>(
2400    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2401    count: usize,
2402) {
2403    do_char_delete(ed, false, count.max(1));
2404    if !ed.vim.replaying {
2405        ed.vim.last_change = Some(LastChange::CharDel {
2406            forward: false,
2407            count: count.max(1),
2408        });
2409    }
2410}
2411
2412/// `s` — substitute `count` chars (delete then enter Insert). Equivalent to
2413/// `cl`. Records `LastChange::OpMotion` for dot-repeat.
2414pub(crate) fn substitute_char_bridge<H: crate::types::Host>(
2415    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2416    count: usize,
2417) {
2418    use hjkl_buffer::{Edit, MotionKind, Position};
2419    ed.push_undo();
2420    ed.sync_buffer_content_from_textarea();
2421    for _ in 0..count.max(1) {
2422        let cursor = buf_cursor_pos(&ed.buffer);
2423        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
2424        if cursor.col >= line_chars {
2425            break;
2426        }
2427        ed.mutate_edit(Edit::DeleteRange {
2428            start: cursor,
2429            end: Position::new(cursor.row, cursor.col + 1),
2430            kind: MotionKind::Char,
2431        });
2432    }
2433    ed.push_buffer_cursor_to_textarea();
2434    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
2435    if !ed.vim.replaying {
2436        ed.vim.last_change = Some(LastChange::OpMotion {
2437            op: Operator::Change,
2438            motion: Motion::Right,
2439            count: count.max(1),
2440            inserted: None,
2441        });
2442    }
2443}
2444
2445/// `S` — substitute the whole line (delete line contents then enter Insert).
2446/// Equivalent to `cc`. Records `LastChange::LineOp` for dot-repeat.
2447pub(crate) fn substitute_line_bridge<H: crate::types::Host>(
2448    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2449    count: usize,
2450) {
2451    execute_line_op(ed, Operator::Change, count.max(1));
2452    if !ed.vim.replaying {
2453        ed.vim.last_change = Some(LastChange::LineOp {
2454            op: Operator::Change,
2455            count: count.max(1),
2456            inserted: None,
2457        });
2458    }
2459}
2460
2461/// `D` — delete from the cursor to end-of-line, writing to the unnamed
2462/// register. Cursor parks on the new last char. Records for dot-repeat.
2463pub(crate) fn delete_to_eol_bridge<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2464    ed.push_undo();
2465    delete_to_eol(ed);
2466    crate::motions::move_left(&mut ed.buffer, 1);
2467    ed.push_buffer_cursor_to_textarea();
2468    if !ed.vim.replaying {
2469        ed.vim.last_change = Some(LastChange::DeleteToEol { inserted: None });
2470    }
2471}
2472
2473/// `C` — change from the cursor to end-of-line (delete then enter Insert).
2474/// Equivalent to `c$`. Shares the delete path with `D`.
2475pub(crate) fn change_to_eol_bridge<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2476    ed.push_undo();
2477    delete_to_eol(ed);
2478    begin_insert_noundo(ed, 1, InsertReason::DeleteToEol);
2479}
2480
2481/// `Y` — yank from the cursor to end-of-line (same as `y$` in Vim 8 default).
2482pub(crate) fn yank_to_eol_bridge<H: crate::types::Host>(
2483    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2484    count: usize,
2485) {
2486    apply_op_with_motion(ed, Operator::Yank, &Motion::LineEnd, count.max(1));
2487}
2488
2489/// `J` — join `count` lines (default 2) onto the current one, inserting a
2490/// single space between each pair (vim semantics). Records for dot-repeat.
2491pub(crate) fn join_line_bridge<H: crate::types::Host>(
2492    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2493    count: usize,
2494) {
2495    // vim `[count]J` joins `count` lines together — i.e. `count - 1` joins.
2496    // Bare `J` (and `1J`) join the current line with the one below (1 join).
2497    let joins = count.max(2) - 1;
2498    for _ in 0..joins {
2499        ed.push_undo();
2500        join_line(ed);
2501    }
2502    if !ed.vim.replaying {
2503        ed.vim.last_change = Some(LastChange::JoinLine { count: joins });
2504    }
2505}
2506
2507/// `~` — toggle the case of `count` chars from the cursor, advancing right.
2508/// Records `LastChange::ToggleCase` for dot-repeat.
2509pub(crate) fn toggle_case_at_cursor_bridge<H: crate::types::Host>(
2510    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2511    count: usize,
2512) {
2513    for _ in 0..count.max(1) {
2514        ed.push_undo();
2515        toggle_case_at_cursor(ed);
2516    }
2517    if !ed.vim.replaying {
2518        ed.vim.last_change = Some(LastChange::ToggleCase {
2519            count: count.max(1),
2520        });
2521    }
2522}
2523
2524/// `p` — paste the unnamed register (or `"reg` register) after the cursor.
2525/// Linewise yanks open a new line below; charwise pastes inline.
2526/// Records `LastChange::Paste` for dot-repeat.
2527pub(crate) fn paste_after_bridge<H: crate::types::Host>(
2528    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2529    count: usize,
2530) {
2531    paste_bridge(ed, false, count, false, false);
2532}
2533
2534/// `P` — paste the unnamed register (or `"reg` register) before the cursor.
2535/// Linewise yanks open a new line above; charwise pastes inline.
2536/// Records `LastChange::Paste` for dot-repeat.
2537pub(crate) fn paste_before_bridge<H: crate::types::Host>(
2538    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2539    count: usize,
2540) {
2541    paste_bridge(ed, true, count, false, false);
2542}
2543
2544/// Shared paste entry for `p`/`P`, `gp`/`gP` (`cursor_after`), and
2545/// `]p`/`[p` (`reindent`). Records `LastChange::Paste` for dot-repeat.
2546pub(crate) fn paste_bridge<H: crate::types::Host>(
2547    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2548    before: bool,
2549    count: usize,
2550    cursor_after: bool,
2551    reindent: bool,
2552) {
2553    do_paste(ed, before, count.max(1), cursor_after, reindent);
2554    if !ed.vim.replaying {
2555        ed.vim.last_change = Some(LastChange::Paste {
2556            before,
2557            count: count.max(1),
2558            cursor_after,
2559            reindent,
2560        });
2561    }
2562}
2563
2564// ── Jump bridges ───────────────────────────────────────────────────────────
2565
2566/// `<C-o>` — jump back `count` entries in the jumplist, saving the current
2567/// position on the forward stack so `<C-i>` can return.
2568pub(crate) fn jump_back_bridge<H: crate::types::Host>(
2569    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2570    count: usize,
2571) {
2572    for _ in 0..count.max(1) {
2573        jump_back(ed);
2574    }
2575}
2576
2577/// `<C-i>` / `Tab` — redo `count` jumps on the forward stack, saving the
2578/// current position on the backward stack.
2579pub(crate) fn jump_forward_bridge<H: crate::types::Host>(
2580    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2581    count: usize,
2582) {
2583    for _ in 0..count.max(1) {
2584        jump_forward(ed);
2585    }
2586}
2587
2588// ── Scroll bridges ─────────────────────────────────────────────────────────
2589
2590/// `<C-f>` / `<C-b>` — scroll the cursor by one full viewport height
2591/// (`h - 2` rows to preserve two-line overlap). `count` multiplies.
2592pub(crate) fn scroll_full_page_bridge<H: crate::types::Host>(
2593    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2594    dir: ScrollDir,
2595    count: usize,
2596) {
2597    ed.vim.scroll_anim_hint = true;
2598    let rows = viewport_full_rows(ed, count) as isize;
2599    match dir {
2600        ScrollDir::Down => scroll_cursor_rows(ed, rows),
2601        ScrollDir::Up => scroll_cursor_rows(ed, -rows),
2602    }
2603}
2604
2605/// `<C-d>` / `<C-u>` — scroll the cursor by half the viewport height.
2606/// `count` multiplies.
2607pub(crate) fn scroll_half_page_bridge<H: crate::types::Host>(
2608    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2609    dir: ScrollDir,
2610    count: usize,
2611) {
2612    ed.vim.scroll_anim_hint = true;
2613    let rows = viewport_half_rows(ed, count) as isize;
2614    match dir {
2615        ScrollDir::Down => scroll_cursor_rows(ed, rows),
2616        ScrollDir::Up => scroll_cursor_rows(ed, -rows),
2617    }
2618}
2619
2620/// `<C-e>` / `<C-y>` — scroll the viewport `count` lines without moving the
2621/// cursor (cursor is clamped to the new visible region if it would go
2622/// off-screen). `<C-e>` scrolls down; `<C-y>` scrolls up.
2623pub(crate) fn scroll_line_bridge<H: crate::types::Host>(
2624    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2625    dir: ScrollDir,
2626    count: usize,
2627) {
2628    let n = count.max(1);
2629    let total = buf_row_count(&ed.buffer);
2630    let last = total.saturating_sub(1);
2631    let h = ed.viewport_height_value() as usize;
2632    let vp = ed.host().viewport();
2633    let cur_top = vp.top_row;
2634    let new_top = match dir {
2635        ScrollDir::Down => (cur_top + n).min(last),
2636        ScrollDir::Up => cur_top.saturating_sub(n),
2637    };
2638    ed.set_viewport_top(new_top);
2639    // Clamp cursor to stay within the new visible region.
2640    let (row, col) = ed.cursor();
2641    let bot = (new_top + h).saturating_sub(1).min(last);
2642    let clamped = row.max(new_top).min(bot);
2643    if clamped != row {
2644        buf_set_cursor_rc(&mut ed.buffer, clamped, col);
2645        ed.push_buffer_cursor_to_textarea();
2646    }
2647}
2648
2649// ── Search bridges ─────────────────────────────────────────────────────────
2650
2651/// `n` / `N` — repeat the last search `count` times. `forward = true` means
2652/// repeat in the original search direction; `false` inverts it (like `N`).
2653pub(crate) fn search_repeat_bridge<H: crate::types::Host>(
2654    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2655    forward: bool,
2656    count: usize,
2657) {
2658    if let Some(pattern) = ed.vim.last_search.clone() {
2659        ed.push_search_pattern(&pattern);
2660    }
2661    if ed.search_state().pattern.is_none() {
2662        return;
2663    }
2664    let go_forward = ed.vim.last_search_forward == forward;
2665    for _ in 0..count.max(1) {
2666        if go_forward {
2667            ed.search_advance_forward(true);
2668        } else {
2669            ed.search_advance_backward(true);
2670        }
2671    }
2672    ed.push_buffer_cursor_to_textarea();
2673}
2674
2675/// `*` / `#` / `g*` / `g#` — search for the word under the cursor.
2676/// `forward` picks search direction; `whole_word` wraps in `\b...\b`.
2677/// `count` repeats the advance.
2678pub(crate) fn word_search_bridge<H: crate::types::Host>(
2679    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2680    forward: bool,
2681    whole_word: bool,
2682    count: usize,
2683) {
2684    word_at_cursor_search(ed, forward, whole_word, count.max(1));
2685}
2686
2687// ── Undo / redo confirmation wrappers (already public on Editor) ───────────
2688
2689/// `u` bridge — identical to `do_undo`; retained for Phase 6.6b audit.
2690/// The FSM now calls `ed.undo()` directly (Phase 6.6a).
2691#[allow(dead_code)]
2692#[inline]
2693pub(crate) fn do_undo_bridge<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2694    do_undo(ed);
2695}
2696
2697// ─── Phase 6.3: visual-mode primitive bridges ──────────────────────────────
2698//
2699// Each `pub(crate)` free function is the extractable body of one visual-mode
2700// transition. These bridges set `vim.mode` directly AND write `current_mode`
2701// so that `Editor::vim_mode()` can read from the stable field without going
2702// through `public_mode()`.
2703//
2704// Pattern identical to Phase 6.1 / 6.2:
2705//   - Bridge fn is `pub(crate) fn *_bridge<H: Host>(ed, …)` in this file.
2706//   - Public wrapper is `pub fn *(&mut self, …)` in `editor.rs` with rustdoc.
2707
2708/// Drop the `Blame` view overlay whenever the input mode is no longer
2709/// `Normal`. BLAME is a Normal-only read-only view; entering Insert/Visual/etc.
2710/// (by keyboard, mouse drag, or programmatic transition) implicitly leaves it.
2711/// Called from every mode-transition funnel so the FSM is the single source of
2712/// truth — the host never has to police this.
2713#[inline]
2714pub fn drop_blame_if_left_normal<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2715    if ed.vim.current_mode != crate::VimMode::Normal {
2716        ed.view = crate::ViewMode::Normal;
2717    }
2718}
2719
2720/// Helper — set both the FSM-internal `mode` and the stable `current_mode`
2721/// field in one call. Every Phase 6.3 bridge that changes mode calls this so
2722/// `vim_mode()` stays correct without going through the FSM's `step()` loop.
2723#[inline]
2724pub(crate) fn set_vim_mode_bridge<H: crate::types::Host>(
2725    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2726    mode: Mode,
2727) {
2728    ed.vim.mode = mode;
2729    ed.vim.current_mode = ed.vim.public_mode();
2730    drop_blame_if_left_normal(ed);
2731}
2732
2733/// `v` from Normal — enter charwise Visual mode. Anchors at the current
2734/// cursor position; the cursor IS the live end of the selection.
2735pub(crate) fn enter_visual_char_bridge<H: crate::types::Host>(
2736    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2737) {
2738    let cur = ed.cursor();
2739    ed.vim.visual_anchor = cur;
2740    set_vim_mode_bridge(ed, Mode::Visual);
2741}
2742
2743/// `V` from Normal — enter linewise Visual mode. Anchors the whole line
2744/// containing the current cursor; `o` still swaps the anchor row.
2745pub(crate) fn enter_visual_line_bridge<H: crate::types::Host>(
2746    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2747) {
2748    let (row, _) = ed.cursor();
2749    ed.vim.visual_line_anchor = row;
2750    set_vim_mode_bridge(ed, Mode::VisualLine);
2751}
2752
2753/// `<C-v>` from Normal — enter Visual-block mode. Anchors at the current
2754/// cursor; `block_vcol` is seeded from the cursor column so h/l navigation
2755/// preserves the desired virtual column.
2756pub(crate) fn enter_visual_block_bridge<H: crate::types::Host>(
2757    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2758) {
2759    let cur = ed.cursor();
2760    ed.vim.block_anchor = cur;
2761    ed.vim.block_vcol = cur.1;
2762    set_vim_mode_bridge(ed, Mode::VisualBlock);
2763}
2764
2765/// Esc from any visual mode — set `<` / `>` marks (per `:h v_:`), stash the
2766/// selection for `gv` re-entry, and return to Normal. Replicates the
2767/// `pre_visual_snapshot` logic in `step()` so callers outside the FSM get
2768/// identical behaviour.
2769pub(crate) fn exit_visual_to_normal_bridge<H: crate::types::Host>(
2770    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2771) {
2772    // Build the same snapshot that `step()` captures at pre-step time.
2773    let snap: Option<LastVisual> = match ed.vim.mode {
2774        Mode::Visual => Some(LastVisual {
2775            mode: Mode::Visual,
2776            anchor: ed.vim.visual_anchor,
2777            cursor: ed.cursor(),
2778            block_vcol: 0,
2779        }),
2780        Mode::VisualLine => Some(LastVisual {
2781            mode: Mode::VisualLine,
2782            anchor: (ed.vim.visual_line_anchor, 0),
2783            cursor: ed.cursor(),
2784            block_vcol: 0,
2785        }),
2786        Mode::VisualBlock => Some(LastVisual {
2787            mode: Mode::VisualBlock,
2788            anchor: ed.vim.block_anchor,
2789            cursor: ed.cursor(),
2790            block_vcol: ed.vim.block_vcol,
2791        }),
2792        _ => None,
2793    };
2794    // Transition to Normal first (matches FSM order).
2795    ed.vim.pending = Pending::None;
2796    ed.vim.count = 0;
2797    ed.vim.insert_session = None;
2798    set_vim_mode_bridge(ed, Mode::Normal);
2799    // Set `<` / `>` marks and stash `last_visual` — mirrors the post-step
2800    // logic in `step()` that fires when a visual → non-visual transition
2801    // is detected.
2802    if let Some(snap) = snap {
2803        let (lo, hi) = match snap.mode {
2804            Mode::Visual => {
2805                if snap.anchor <= snap.cursor {
2806                    (snap.anchor, snap.cursor)
2807                } else {
2808                    (snap.cursor, snap.anchor)
2809                }
2810            }
2811            Mode::VisualLine => {
2812                let r_lo = snap.anchor.0.min(snap.cursor.0);
2813                let r_hi = snap.anchor.0.max(snap.cursor.0);
2814                let vl_rope = ed.buffer().rope();
2815                let r_hi_clamped = r_hi.min(vl_rope.len_lines().saturating_sub(1));
2816                let last_col = hjkl_buffer::rope_line_str(&vl_rope, r_hi_clamped)
2817                    .chars()
2818                    .count()
2819                    .saturating_sub(1);
2820                ((r_lo, 0), (r_hi, last_col))
2821            }
2822            Mode::VisualBlock => {
2823                let (r1, c1) = snap.anchor;
2824                let (r2, c2) = snap.cursor;
2825                ((r1.min(r2), c1.min(c2)), (r1.max(r2), c1.max(c2)))
2826            }
2827            _ => {
2828                if snap.anchor <= snap.cursor {
2829                    (snap.anchor, snap.cursor)
2830                } else {
2831                    (snap.cursor, snap.anchor)
2832                }
2833            }
2834        };
2835        ed.set_mark('<', lo);
2836        ed.set_mark('>', hi);
2837        ed.vim.last_visual = Some(snap);
2838    }
2839}
2840
2841/// `o` in Visual / VisualLine / VisualBlock — swap the cursor and anchor
2842/// without mutating the selection range. In charwise mode the cursor jumps
2843/// to the old anchor and the anchor takes the old cursor. In linewise mode
2844/// the anchor *row* swaps with the current cursor row. In block mode the
2845/// block corners swap.
2846pub(crate) fn visual_o_toggle_bridge<H: crate::types::Host>(
2847    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2848) {
2849    match ed.vim.mode {
2850        Mode::Visual => {
2851            let cur = ed.cursor();
2852            let anchor = ed.vim.visual_anchor;
2853            ed.vim.visual_anchor = cur;
2854            ed.jump_cursor(anchor.0, anchor.1);
2855        }
2856        Mode::VisualLine => {
2857            let cur_row = ed.cursor().0;
2858            let anchor_row = ed.vim.visual_line_anchor;
2859            ed.vim.visual_line_anchor = cur_row;
2860            ed.jump_cursor(anchor_row, 0);
2861        }
2862        Mode::VisualBlock => {
2863            let cur = ed.cursor();
2864            let anchor = ed.vim.block_anchor;
2865            ed.vim.block_anchor = cur;
2866            ed.vim.block_vcol = anchor.1;
2867            ed.jump_cursor(anchor.0, anchor.1);
2868        }
2869        _ => {}
2870    }
2871}
2872
2873/// `gv` — restore the last visual selection (mode + anchor + cursor).
2874/// No-op if no selection was ever stored. Mirrors the `gv` arm in
2875/// `handle_normal_g`.
2876pub(crate) fn reenter_last_visual_bridge<H: crate::types::Host>(
2877    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2878) {
2879    if let Some(snap) = ed.vim.last_visual {
2880        match snap.mode {
2881            Mode::Visual => {
2882                ed.vim.visual_anchor = snap.anchor;
2883                set_vim_mode_bridge(ed, Mode::Visual);
2884            }
2885            Mode::VisualLine => {
2886                ed.vim.visual_line_anchor = snap.anchor.0;
2887                set_vim_mode_bridge(ed, Mode::VisualLine);
2888            }
2889            Mode::VisualBlock => {
2890                ed.vim.block_anchor = snap.anchor;
2891                ed.vim.block_vcol = snap.block_vcol;
2892                set_vim_mode_bridge(ed, Mode::VisualBlock);
2893            }
2894            _ => {}
2895        }
2896        ed.jump_cursor(snap.cursor.0, snap.cursor.1);
2897    }
2898}
2899
2900/// Direct mode-transition entry point for external controllers (e.g.
2901/// hjkl-vim). Sets both the FSM-internal `mode` and the stable
2902/// `current_mode`. Use sparingly — prefer the semantic primitives
2903/// (`enter_visual_char_bridge`, `enter_insert_i_bridge`, …) which also
2904/// set up the required bookkeeping (anchors, sessions, …).
2905pub(crate) fn set_mode_bridge<H: crate::types::Host>(
2906    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2907    mode: crate::VimMode,
2908) {
2909    let internal = match mode {
2910        crate::VimMode::Normal => Mode::Normal,
2911        crate::VimMode::Insert => Mode::Insert,
2912        crate::VimMode::Visual => Mode::Visual,
2913        crate::VimMode::VisualLine => Mode::VisualLine,
2914        crate::VimMode::VisualBlock => Mode::VisualBlock,
2915    };
2916    ed.vim.mode = internal;
2917    ed.vim.current_mode = mode;
2918    drop_blame_if_left_normal(ed);
2919}
2920
2921// ─── Normal / Visual / Operator-pending dispatcher removed in Phase 6.6g.3 ──
2922//
2923// `step_normal` and all private dispatch helpers (handle_after_op,
2924// handle_after_g, handle_after_z, handle_normal_only, etc.) were deleted.
2925// The canonical FSM body lives in `hjkl-vim::normal`. Use
2926// `hjkl_vim::dispatch_input` as the entry point.
2927//
2928// DELETED FUNCTION SIGNATURE (for archaeology):
2929// pub(crate) fn step_normal<H: crate::types::Host>(ed: ..., input: Input) -> bool {
2930
2931/// `m{ch}` — public controller entry point. Validates `ch` (must be
2932/// alphanumeric to match vim's mark-name rules) and records the current
2933/// cursor position under that name. Promoted to the public surface in 0.6.7
2934/// so the hjkl-vim `PendingState::SetMark` reducer can dispatch
2935/// `EngineCmd::SetMark` without re-entering the engine FSM.
2936/// `handle_set_mark` delegates here to avoid logic duplication.
2937pub(crate) fn set_mark_at_cursor<H: crate::types::Host>(
2938    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2939    ch: char,
2940) {
2941    if ch.is_ascii_lowercase() {
2942        let pos = ed.cursor();
2943        ed.set_mark(ch, pos);
2944    } else if ch.is_ascii_uppercase() {
2945        let pos = ed.cursor();
2946        let bid = ed.current_buffer_id();
2947        ed.set_global_mark(ch, bid, pos);
2948        tracing::debug!(
2949            mark = ch as u32,
2950            buffer_id = bid,
2951            row = pos.0,
2952            col = pos.1,
2953            "global mark set"
2954        );
2955    }
2956    // Invalid chars silently no-op (mirrors handle_set_mark behaviour).
2957}
2958
2959/// `'<ch>` / `` `<ch> `` — public controller entry point for lowercase and
2960/// special marks. Validates `ch` against the set of legal mark names
2961/// (lowercase, special: `'`/`` ` ``/`.`/`[`/`]`/`<`/`>`), resolves the
2962/// target position, and jumps the cursor. `linewise = true` → row only, col
2963/// snaps to first non-blank; `linewise = false` → exact (row, col).
2964///
2965/// Uppercase marks are handled by [`try_goto_mark`] which can return a
2966/// `MarkJump::CrossBuffer` for cross-buffer jumps.
2967pub(crate) fn goto_mark<H: crate::types::Host>(
2968    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2969    ch: char,
2970    linewise: bool,
2971) {
2972    let target = match ch {
2973        'a'..='z' => ed.mark(ch),
2974        '\'' | '`' => ed.vim.jump_back.last().copied(),
2975        '.' => ed.last_edit_pos,
2976        '[' | ']' | '<' | '>' => ed.mark(ch),
2977        _ => None,
2978    };
2979    let Some((row, col)) = target else {
2980        return;
2981    };
2982    let pre = ed.cursor();
2983    let (r, c_clamped) = clamp_pos(ed, (row, col));
2984    if linewise {
2985        buf_set_cursor_rc(&mut ed.buffer, r, 0);
2986        ed.push_buffer_cursor_to_textarea();
2987        move_first_non_whitespace(ed);
2988    } else {
2989        buf_set_cursor_rc(&mut ed.buffer, r, c_clamped);
2990        ed.push_buffer_cursor_to_textarea();
2991    }
2992    if ed.cursor() != pre {
2993        ed.push_jump(pre);
2994    }
2995    ed.sticky_col = Some(ed.cursor().1);
2996}
2997
2998/// Unified mark-jump entry point that returns a [`crate::editor::MarkJump`]
2999/// so the app layer can decide whether to switch buffers.
3000///
3001/// - Uppercase marks (`'A'`–`'Z'`) look in `global_marks`. If the stored
3002///   `buffer_id` differs from `ed.current_buffer_id()`, returns
3003///   `CrossBuffer`. Same-buffer uppercase marks execute the jump normally.
3004/// - All other legal mark chars delegate to [`goto_mark`] and return
3005///   `SameBuffer`.
3006pub(crate) fn try_goto_mark<H: crate::types::Host>(
3007    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3008    ch: char,
3009    linewise: bool,
3010) -> crate::editor::MarkJump {
3011    use crate::editor::MarkJump;
3012    match ch {
3013        'A'..='Z' => {
3014            let Some((bid, row, col)) = ed.global_mark(ch) else {
3015                return MarkJump::Unset;
3016            };
3017            if bid != ed.current_buffer_id() {
3018                tracing::debug!(
3019                    mark = ch as u32,
3020                    buffer_id = bid,
3021                    row,
3022                    col,
3023                    "global mark cross-buffer jump"
3024                );
3025                return MarkJump::CrossBuffer {
3026                    buffer_id: bid,
3027                    row,
3028                    col,
3029                };
3030            }
3031            // Same buffer — execute the jump normally.
3032            let pre = ed.cursor();
3033            let (r, c_clamped) = clamp_pos(ed, (row, col));
3034            if linewise {
3035                buf_set_cursor_rc(&mut ed.buffer, r, 0);
3036                ed.push_buffer_cursor_to_textarea();
3037                move_first_non_whitespace(ed);
3038            } else {
3039                buf_set_cursor_rc(&mut ed.buffer, r, c_clamped);
3040                ed.push_buffer_cursor_to_textarea();
3041            }
3042            if ed.cursor() != pre {
3043                ed.push_jump(pre);
3044            }
3045            ed.sticky_col = Some(ed.cursor().1);
3046            MarkJump::SameBuffer
3047        }
3048        'a'..='z' | '\'' | '`' | '.' | '[' | ']' | '<' | '>' => {
3049            goto_mark(ed, ch, linewise);
3050            MarkJump::SameBuffer
3051        }
3052        _ => MarkJump::Unset,
3053    }
3054}
3055
3056/// `true` when `op` records a `last_change` entry for dot-repeat purposes.
3057/// Promoted to `pub` in Phase 6.6e so `hjkl-vim::normal` can use it without
3058/// duplicating the logic.
3059pub fn op_is_change(op: Operator) -> bool {
3060    matches!(op, Operator::Delete | Operator::Change)
3061}
3062
3063// ─── Jumplist (Ctrl-o / Ctrl-i) ────────────────────────────────────────────
3064
3065/// `Ctrl-o` — jump back to the most recent pre-jump position. Saves
3066/// the current cursor onto the forward stack so `Ctrl-i` can return.
3067fn jump_back<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
3068    let Some(target) = ed.vim.jump_back.pop() else {
3069        return;
3070    };
3071    let cur = ed.cursor();
3072    ed.vim.jump_fwd.push(cur);
3073    let (r, c) = clamp_pos(ed, target);
3074    ed.jump_cursor(r, c);
3075    ed.sticky_col = Some(c);
3076}
3077
3078/// `Ctrl-i` / `Tab` — redo the last `Ctrl-o`. Saves the current cursor
3079/// onto the back stack.
3080fn jump_forward<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
3081    let Some(target) = ed.vim.jump_fwd.pop() else {
3082        return;
3083    };
3084    let cur = ed.cursor();
3085    ed.vim.jump_back.push(cur);
3086    if ed.vim.jump_back.len() > JUMPLIST_MAX {
3087        ed.vim.jump_back.remove(0);
3088    }
3089    let (r, c) = clamp_pos(ed, target);
3090    ed.jump_cursor(r, c);
3091    ed.sticky_col = Some(c);
3092}
3093
3094/// Clamp a stored `(row, col)` to the live buffer in case edits
3095/// shrunk the document between push and pop.
3096fn clamp_pos<H: crate::types::Host>(
3097    ed: &Editor<hjkl_buffer::Buffer, H>,
3098    pos: (usize, usize),
3099) -> (usize, usize) {
3100    let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
3101    let r = pos.0.min(last_row);
3102    let line_len = buf_line_chars(&ed.buffer, r);
3103    let c = pos.1.min(line_len.saturating_sub(1));
3104    (r, c)
3105}
3106
3107/// True for motions that vim treats as jumps (pushed onto the jumplist).
3108fn is_big_jump(motion: &Motion) -> bool {
3109    matches!(
3110        motion,
3111        Motion::FileTop
3112            | Motion::FileBottom
3113            | Motion::MatchBracket
3114            | Motion::WordAtCursor { .. }
3115            | Motion::SearchNext { .. }
3116            | Motion::ViewportTop
3117            | Motion::ViewportMiddle
3118            | Motion::ViewportBottom
3119    )
3120}
3121
3122// ─── Scroll helpers (Ctrl-d / Ctrl-u / Ctrl-f / Ctrl-b) ────────────────────
3123
3124/// Half-viewport row count, with a floor of 1 so tiny / un-rendered
3125/// viewports still step by a single row. `count` multiplies.
3126fn viewport_half_rows<H: crate::types::Host>(
3127    ed: &Editor<hjkl_buffer::Buffer, H>,
3128    count: usize,
3129) -> usize {
3130    let h = ed.viewport_height_value() as usize;
3131    (h / 2).max(1).saturating_mul(count.max(1))
3132}
3133
3134/// Full-viewport row count. Vim conventionally keeps 2 lines of overlap
3135/// between successive `Ctrl-f` pages; we approximate with `h - 2`.
3136fn viewport_full_rows<H: crate::types::Host>(
3137    ed: &Editor<hjkl_buffer::Buffer, H>,
3138    count: usize,
3139) -> usize {
3140    let h = ed.viewport_height_value() as usize;
3141    h.saturating_sub(2).max(1).saturating_mul(count.max(1))
3142}
3143
3144/// Move the cursor by `delta` rows (positive = down, negative = up),
3145/// clamp to the document, then land at the first non-blank on the new
3146/// row. The textarea viewport auto-scrolls to keep the cursor visible
3147/// when the cursor pushes off-screen.
3148fn scroll_cursor_rows<H: crate::types::Host>(
3149    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3150    delta: isize,
3151) {
3152    if delta == 0 {
3153        return;
3154    }
3155    ed.sync_buffer_content_from_textarea();
3156    let (row, _) = ed.cursor();
3157    let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
3158    let target = (row as isize + delta).max(0).min(last_row as isize) as usize;
3159    buf_set_cursor_rc(&mut ed.buffer, target, 0);
3160    crate::motions::move_first_non_blank(&mut ed.buffer);
3161    ed.push_buffer_cursor_to_textarea();
3162    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
3163}
3164
3165// ─── Motion parsing ────────────────────────────────────────────────────────
3166
3167/// Parse the first key of a normal/visual-mode motion. Returns `None` for
3168/// keys that don't start a motion (operator keys, command keys, etc.).
3169/// Promoted to `pub` in Phase 6.6e so `hjkl-vim::normal` can call it.
3170pub fn parse_motion(input: &Input) -> Option<Motion> {
3171    if input.ctrl {
3172        // `<C-h>` is vim's `<BS>` — a wrapping left motion. (The hjkl app
3173        // rebinds `<C-h>` to window-focus-left before it reaches the engine;
3174        // this keeps it correct for engine consumers that don't override it.)
3175        if input.key == Key::Char('h') {
3176            return Some(Motion::BackspaceBack);
3177        }
3178        return None;
3179    }
3180    match input.key {
3181        Key::Char('h') | Key::Left => Some(Motion::Left),
3182        Key::Char('l') | Key::Right => Some(Motion::Right),
3183        // `<Space>`/`<BS>` are vim's right/left motions that WRAP at line ends
3184        // (default `whichwrap=b,s`), unlike `l`/`h`/arrows which never wrap.
3185        // Operators (`d<Space>`/`d<BS>`) act on one char mid-line like `dl`/`dh`.
3186        Key::Char(' ') => Some(Motion::SpaceFwd),
3187        Key::Backspace => Some(Motion::BackspaceBack),
3188        Key::Char('j') | Key::Down => Some(Motion::Down),
3189        // `+` / `<CR>` — first non-blank of next line (linewise, count-aware).
3190        Key::Char('+') | Key::Enter => Some(Motion::FirstNonBlankNextLine),
3191        // `-` — first non-blank of previous line (linewise, count-aware).
3192        Key::Char('-') => Some(Motion::FirstNonBlankPrevLine),
3193        // `_` — first non-blank of current line, or count-1 lines down (linewise).
3194        Key::Char('_') => Some(Motion::FirstNonBlankLine),
3195        Key::Char('k') | Key::Up => Some(Motion::Up),
3196        Key::Char('w') => Some(Motion::WordFwd),
3197        Key::Char('W') => Some(Motion::BigWordFwd),
3198        Key::Char('b') => Some(Motion::WordBack),
3199        Key::Char('B') => Some(Motion::BigWordBack),
3200        Key::Char('e') => Some(Motion::WordEnd),
3201        Key::Char('E') => Some(Motion::BigWordEnd),
3202        Key::Char('0') | Key::Home => Some(Motion::LineStart),
3203        Key::Char('^') => Some(Motion::FirstNonBlank),
3204        Key::Char('$') | Key::End => Some(Motion::LineEnd),
3205        Key::Char('G') => Some(Motion::FileBottom),
3206        Key::Char('%') => Some(Motion::MatchBracket),
3207        Key::Char(';') => Some(Motion::FindRepeat { reverse: false }),
3208        Key::Char(',') => Some(Motion::FindRepeat { reverse: true }),
3209        Key::Char('*') => Some(Motion::WordAtCursor {
3210            forward: true,
3211            whole_word: true,
3212        }),
3213        Key::Char('#') => Some(Motion::WordAtCursor {
3214            forward: false,
3215            whole_word: true,
3216        }),
3217        Key::Char('n') => Some(Motion::SearchNext { reverse: false }),
3218        Key::Char('N') => Some(Motion::SearchNext { reverse: true }),
3219        Key::Char('H') => Some(Motion::ViewportTop),
3220        Key::Char('M') => Some(Motion::ViewportMiddle),
3221        Key::Char('L') => Some(Motion::ViewportBottom),
3222        Key::Char('{') => Some(Motion::ParagraphPrev),
3223        Key::Char('}') => Some(Motion::ParagraphNext),
3224        Key::Char('(') => Some(Motion::SentencePrev),
3225        Key::Char(')') => Some(Motion::SentenceNext),
3226        Key::Char('|') => Some(Motion::GotoColumn),
3227        _ => None,
3228    }
3229}
3230
3231// ─── Motion execution ──────────────────────────────────────────────────────
3232
3233pub(crate) fn execute_motion<H: crate::types::Host>(
3234    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3235    motion: Motion,
3236    count: usize,
3237) {
3238    let count = count.max(1);
3239    // `;`/`,` smart fallback: if the last horizontal motion was a sneak
3240    // digraph, repeat via apply_sneak instead of find-char.
3241    if let Motion::FindRepeat { reverse } = motion
3242        && ed.vim.last_horizontal_motion == LastHorizontalMotion::Sneak
3243    {
3244        if let Some(((c1, c2), fwd)) = ed.vim.last_sneak {
3245            let effective_fwd = if reverse { !fwd } else { fwd };
3246            apply_sneak(ed, c1, c2, effective_fwd, count);
3247        }
3248        return;
3249    }
3250    // FindRepeat needs the stored direction.
3251    let motion = match motion {
3252        Motion::FindRepeat { reverse } => match ed.vim.last_find {
3253            Some((ch, forward, till)) => Motion::Find {
3254                ch,
3255                forward: if reverse { !forward } else { forward },
3256                till,
3257            },
3258            None => return,
3259        },
3260        other => other,
3261    };
3262    let pre_pos = ed.cursor();
3263    let pre_col = pre_pos.1;
3264    apply_motion_cursor(ed, &motion, count);
3265    let post_pos = ed.cursor();
3266    if is_big_jump(&motion) && pre_pos != post_pos {
3267        ed.push_jump(pre_pos);
3268    }
3269    apply_sticky_col(ed, &motion, pre_col);
3270    // Phase 7b: keep the migration buffer's cursor + viewport in
3271    // lockstep with the textarea after every motion. Once 7c lands
3272    // (motions ported onto the buffer's API), this flips: the
3273    // buffer becomes authoritative and the textarea mirrors it.
3274    ed.sync_buffer_from_textarea();
3275}
3276
3277// ─── Keymap-layer motion controller ────────────────────────────────────────
3278
3279/// Wrapper around `execute_motion` that also syncs `block_vcol` when in
3280/// VisualBlock mode. The engine FSM's `step()` already does this (line ~2001);
3281/// the keymap path (`apply_motion_kind`) must do the same so VisualBlock h/l
3282/// extend the highlighted region correctly.
3283///
3284/// `update_block_vcol` is only a no-op for vertical / non-horizontal motions
3285/// (Up, Down, FileTop, FileBottom, Search), so passing every motion through is
3286/// safe — the function's own match arm handles the no-op case.
3287fn execute_motion_with_block_vcol<H: crate::types::Host>(
3288    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3289    motion: Motion,
3290    count: usize,
3291) {
3292    let motion_copy = motion.clone();
3293    execute_motion(ed, motion, count);
3294    if ed.vim.mode == Mode::VisualBlock {
3295        update_block_vcol(ed, &motion_copy);
3296    }
3297}
3298
3299/// Execute a `crate::MotionKind` cursor motion. Called by the host's
3300/// `Editor::apply_motion` controller method — the keymap dispatch path for
3301/// Phase 3a of kryptic-sh/hjkl#69.
3302///
3303/// Maps each variant to the same internal primitives used by the engine FSM
3304/// so cursor, sticky column, scroll, and sync semantics are identical.
3305///
3306/// # Visual-mode post-motion sync audit (2026-05-13)
3307///
3308/// After `execute_motion`, two things are conditional on visual mode:
3309///
3310/// 1. **VisualBlock `block_vcol` sync** — `update_block_vcol(ed, &motion)` is
3311///    called when `mode == Mode::VisualBlock`.  This is replicated here via
3312///    `execute_motion_with_block_vcol` for every motion variant below.
3313///
3314/// 2. **`last_find` update** — `Motion::Find` is dispatched through
3315///    `Pending::Find → apply_find_char` (in hjkl-vim), which writes `last_find`
3316///    itself.  A post-motion `last_find` write here would be dead code.  The keymap
3317///    path writes `last_find` in `apply_find_char` (called from
3318///    `Editor::find_char`), so no gap exists here.
3319///
3320/// No VisualLine-specific or Visual-specific post-motion work exists in the
3321/// FSM: anchors (`visual_anchor`, `visual_line_anchor`, `block_anchor`) are
3322/// only written on mode-entry or `o`-swap, never on motion.  The `<`/`>`
3323/// mark update in `step()` fires only on visual→normal transition, not after
3324/// each motion.  There are **no further sync gaps** beyond the `block_vcol`
3325/// fix already applied above.
3326pub(crate) fn apply_motion_kind<H: crate::types::Host>(
3327    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3328    kind: crate::MotionKind,
3329    count: usize,
3330) {
3331    let count = count.max(1);
3332    match kind {
3333        crate::MotionKind::CharLeft => {
3334            execute_motion_with_block_vcol(ed, Motion::Left, count);
3335        }
3336        crate::MotionKind::CharRight => {
3337            execute_motion_with_block_vcol(ed, Motion::Right, count);
3338        }
3339        crate::MotionKind::LineDown => {
3340            execute_motion_with_block_vcol(ed, Motion::Down, count);
3341        }
3342        crate::MotionKind::LineUp => {
3343            execute_motion_with_block_vcol(ed, Motion::Up, count);
3344        }
3345        crate::MotionKind::FirstNonBlankDown => {
3346            // `+`: move down `count` lines then land on first non-blank.
3347            // Not a big-jump (no jump-list entry), sticky col set to the
3348            // landed column (first non-blank). Mirrors scroll_cursor_rows
3349            // semantics but goes through the fold-aware buffer motion path.
3350            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3351            crate::motions::move_down(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
3352            crate::motions::move_first_non_blank(&mut ed.buffer);
3353            ed.push_buffer_cursor_to_textarea();
3354            ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
3355            ed.sync_buffer_from_textarea();
3356        }
3357        crate::MotionKind::FirstNonBlankUp => {
3358            // `-`: move up `count` lines then land on first non-blank.
3359            // Same pattern as FirstNonBlankDown, direction reversed.
3360            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3361            crate::motions::move_up(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
3362            crate::motions::move_first_non_blank(&mut ed.buffer);
3363            ed.push_buffer_cursor_to_textarea();
3364            ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
3365            ed.sync_buffer_from_textarea();
3366        }
3367        crate::MotionKind::WordForward => {
3368            execute_motion_with_block_vcol(ed, Motion::WordFwd, count);
3369        }
3370        crate::MotionKind::BigWordForward => {
3371            execute_motion_with_block_vcol(ed, Motion::BigWordFwd, count);
3372        }
3373        crate::MotionKind::WordBackward => {
3374            execute_motion_with_block_vcol(ed, Motion::WordBack, count);
3375        }
3376        crate::MotionKind::BigWordBackward => {
3377            execute_motion_with_block_vcol(ed, Motion::BigWordBack, count);
3378        }
3379        crate::MotionKind::WordEnd => {
3380            execute_motion_with_block_vcol(ed, Motion::WordEnd, count);
3381        }
3382        crate::MotionKind::BigWordEnd => {
3383            execute_motion_with_block_vcol(ed, Motion::BigWordEnd, count);
3384        }
3385        crate::MotionKind::LineStart => {
3386            // `0` / `<Home>`: first column of the current line.
3387            // count is ignored — matches vim `0` semantics.
3388            execute_motion_with_block_vcol(ed, Motion::LineStart, 1);
3389        }
3390        crate::MotionKind::FirstNonBlank => {
3391            // `^`: first non-blank column on the current line.
3392            // count is ignored — matches vim `^` semantics.
3393            execute_motion_with_block_vcol(ed, Motion::FirstNonBlank, 1);
3394        }
3395        crate::MotionKind::GotoLine => {
3396            // `G`: bare `G` → last line; `count G` → jump to line `count`.
3397            // apply_motion_kind normalises the raw count to count.max(1)
3398            // above, so count == 1 means "bare G" (last line) and count > 1
3399            // means "go to line N". execute_motion's FileBottom arm applies
3400            // the same `count > 1` check before calling move_bottom, so the
3401            // convention aligns: pass count straight through.
3402            // FileBottom is vertical — update_block_vcol is a no-op here
3403            // (preserves vcol), so the helper is safe to use.
3404            execute_motion_with_block_vcol(ed, Motion::FileBottom, count);
3405        }
3406        crate::MotionKind::LineEnd => {
3407            // `$` / `<End>`: last character on the current line.
3408            // count is ignored at the keymap-path level (vim `N$` moves
3409            // down N-1 lines then lands at line-end; not yet wired).
3410            execute_motion_with_block_vcol(ed, Motion::LineEnd, 1);
3411        }
3412        crate::MotionKind::FindRepeat => {
3413            // `;` — repeat last f/F/t/T in the same direction.
3414            // execute_motion resolves FindRepeat via ed.vim.last_find;
3415            // no-op if no prior find exists (None arm returns early).
3416            execute_motion_with_block_vcol(ed, Motion::FindRepeat { reverse: false }, count);
3417        }
3418        crate::MotionKind::FindRepeatReverse => {
3419            // `,` — repeat last f/F/t/T in the reverse direction.
3420            // execute_motion resolves FindRepeat via ed.vim.last_find;
3421            // no-op if no prior find exists (None arm returns early).
3422            execute_motion_with_block_vcol(ed, Motion::FindRepeat { reverse: true }, count);
3423        }
3424        crate::MotionKind::BracketMatch => {
3425            // `%` — jump to the matching bracket.
3426            // count is passed through; engine-side matching_bracket handles
3427            // the no-match case as a no-op (cursor stays). Engine FSM arm
3428            // for `%` in parse_motion is kept intact for macro-replay.
3429            execute_motion_with_block_vcol(ed, Motion::MatchBracket, count);
3430        }
3431        crate::MotionKind::ViewportTop => {
3432            // `H` — cursor to top of visible viewport, then count-1 rows down.
3433            // Engine FSM arm for `H` in parse_motion is kept intact for macro-replay.
3434            execute_motion_with_block_vcol(ed, Motion::ViewportTop, count);
3435        }
3436        crate::MotionKind::ViewportMiddle => {
3437            // `M` — cursor to middle of visible viewport; count ignored.
3438            // Engine FSM arm for `M` in parse_motion is kept intact for macro-replay.
3439            execute_motion_with_block_vcol(ed, Motion::ViewportMiddle, count);
3440        }
3441        crate::MotionKind::ViewportBottom => {
3442            // `L` — cursor to bottom of visible viewport, then count-1 rows up.
3443            // Engine FSM arm for `L` in parse_motion is kept intact for macro-replay.
3444            execute_motion_with_block_vcol(ed, Motion::ViewportBottom, count);
3445        }
3446        crate::MotionKind::HalfPageDown => {
3447            // `<C-d>` — half page down, count multiplies the distance.
3448            // Calls scroll_cursor_rows directly rather than adding a Motion enum
3449            // variant, keeping engine Motion churn minimal.
3450            scroll_cursor_rows(ed, viewport_half_rows(ed, count) as isize);
3451        }
3452        crate::MotionKind::HalfPageUp => {
3453            // `<C-u>` — half page up, count multiplies the distance.
3454            // Direct call mirrors the FSM Ctrl-u arm. No new Motion variant.
3455            scroll_cursor_rows(ed, -(viewport_half_rows(ed, count) as isize));
3456        }
3457        crate::MotionKind::FullPageDown => {
3458            // `<C-f>` — full page down (2-line overlap), count multiplies.
3459            // Direct call mirrors the FSM Ctrl-f arm. No new Motion variant.
3460            scroll_cursor_rows(ed, viewport_full_rows(ed, count) as isize);
3461        }
3462        crate::MotionKind::FullPageUp => {
3463            // `<C-b>` — full page up (2-line overlap), count multiplies.
3464            // Direct call mirrors the FSM Ctrl-b arm. No new Motion variant.
3465            scroll_cursor_rows(ed, -(viewport_full_rows(ed, count) as isize));
3466        }
3467        crate::MotionKind::FirstNonBlankLine => {
3468            execute_motion_with_block_vcol(ed, Motion::FirstNonBlankLine, count);
3469        }
3470        crate::MotionKind::SectionBackward => {
3471            execute_motion_with_block_vcol(ed, Motion::SectionBackward, count);
3472        }
3473        crate::MotionKind::SectionForward => {
3474            execute_motion_with_block_vcol(ed, Motion::SectionForward, count);
3475        }
3476        crate::MotionKind::SectionEndBackward => {
3477            execute_motion_with_block_vcol(ed, Motion::SectionEndBackward, count);
3478        }
3479        crate::MotionKind::SectionEndForward => {
3480            execute_motion_with_block_vcol(ed, Motion::SectionEndForward, count);
3481        }
3482    }
3483}
3484
3485/// Restore the cursor to the sticky column after vertical motions and
3486/// sync the sticky column to the current column after horizontal ones.
3487/// `pre_col` is the cursor column captured *before* the motion — used
3488/// to bootstrap the sticky value on the very first motion.
3489fn apply_sticky_col<H: crate::types::Host>(
3490    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3491    motion: &Motion,
3492    pre_col: usize,
3493) {
3494    if is_vertical_motion(motion) {
3495        let want = ed.sticky_col.unwrap_or(pre_col);
3496        // Record the desired column so the next vertical motion sees
3497        // it even if we currently clamped to a shorter row.
3498        ed.sticky_col = Some(want);
3499        let (row, _) = ed.cursor();
3500        let line_len = buf_line_chars(&ed.buffer, row);
3501        // Clamp to the last char on non-empty lines (vim normal-mode
3502        // never parks the cursor one past end of line). Empty lines
3503        // collapse to col 0.
3504        let max_col = line_len.saturating_sub(1);
3505        let target = want.min(max_col);
3506        // raw primitive: this function MUST preserve the un-clamped `want`
3507        // already stored in `ed.sticky_col`; `jump_cursor` would overwrite
3508        // it with the clamped `target`.
3509        buf_set_cursor_rc(&mut ed.buffer, row, target);
3510    } else {
3511        // Horizontal motion or non-motion: sticky column tracks the
3512        // new cursor column so the *next* vertical motion aims there.
3513        ed.sticky_col = Some(ed.cursor().1);
3514    }
3515}
3516
3517fn is_vertical_motion(motion: &Motion) -> bool {
3518    // Only j / k preserve the sticky column. Everything else (search,
3519    // gg / G, word jumps, etc.) lands at the match's own column so the
3520    // sticky value should sync to the new cursor column.
3521    matches!(
3522        motion,
3523        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown
3524    )
3525}
3526
3527fn apply_motion_cursor<H: crate::types::Host>(
3528    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3529    motion: &Motion,
3530    count: usize,
3531) {
3532    apply_motion_cursor_ctx(ed, motion, count, false)
3533}
3534
3535pub(crate) fn apply_motion_cursor_ctx<H: crate::types::Host>(
3536    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3537    motion: &Motion,
3538    count: usize,
3539    as_operator: bool,
3540) {
3541    match motion {
3542        Motion::Left => {
3543            // `h` — Buffer clamps at col 0 (no wrap), matching vim.
3544            crate::motions::move_left(&mut ed.buffer, count);
3545            ed.push_buffer_cursor_to_textarea();
3546        }
3547        Motion::Right => {
3548            // `l` — operator-motion context (`dl`/`cl`/`yl`) is allowed
3549            // one past the last char so the range includes it; cursor
3550            // context clamps at the last char.
3551            if as_operator {
3552                crate::motions::move_right_to_end(&mut ed.buffer, count);
3553            } else {
3554                crate::motions::move_right_in_line(&mut ed.buffer, count);
3555            }
3556            ed.push_buffer_cursor_to_textarea();
3557        }
3558        Motion::SpaceFwd => {
3559            // `<Space>` — wraps to next line at EOL in cursor context; mid-line
3560            // char delete like `l` under an operator (`d<Space>`).
3561            if as_operator {
3562                crate::motions::move_right_to_end(&mut ed.buffer, count);
3563            } else {
3564                crate::motions::move_space_fwd(&mut ed.buffer, count);
3565            }
3566            ed.push_buffer_cursor_to_textarea();
3567        }
3568        Motion::BackspaceBack => {
3569            // `<BS>` — wraps to prev line's last char at BOL in cursor context;
3570            // mid-line char move like `h` under an operator (`d<BS>`).
3571            if as_operator {
3572                crate::motions::move_left(&mut ed.buffer, count);
3573            } else {
3574                crate::motions::move_backspace_back(&mut ed.buffer, count);
3575            }
3576            ed.push_buffer_cursor_to_textarea();
3577        }
3578        Motion::Up => {
3579            // Final col is set by `apply_sticky_col` below — push the
3580            // post-move row to the textarea and let sticky tracking
3581            // finish the work.
3582            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3583            crate::motions::move_up(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
3584            ed.push_buffer_cursor_to_textarea();
3585        }
3586        Motion::Down => {
3587            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3588            crate::motions::move_down(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
3589            ed.push_buffer_cursor_to_textarea();
3590        }
3591        Motion::ScreenUp => {
3592            let v = *ed.host.viewport();
3593            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3594            crate::motions::move_screen_up(&mut ed.buffer, &folds, &v, count, &mut ed.sticky_col);
3595            ed.push_buffer_cursor_to_textarea();
3596        }
3597        Motion::ScreenDown => {
3598            let v = *ed.host.viewport();
3599            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3600            crate::motions::move_screen_down(&mut ed.buffer, &folds, &v, count, &mut ed.sticky_col);
3601            ed.push_buffer_cursor_to_textarea();
3602        }
3603        Motion::WordFwd => {
3604            crate::motions::move_word_fwd(&mut ed.buffer, false, count, &ed.settings.iskeyword);
3605            ed.push_buffer_cursor_to_textarea();
3606        }
3607        Motion::WordBack => {
3608            crate::motions::move_word_back(&mut ed.buffer, false, count, &ed.settings.iskeyword);
3609            ed.push_buffer_cursor_to_textarea();
3610        }
3611        Motion::WordEnd => {
3612            crate::motions::move_word_end(&mut ed.buffer, false, count, &ed.settings.iskeyword);
3613            ed.push_buffer_cursor_to_textarea();
3614        }
3615        Motion::BigWordFwd => {
3616            crate::motions::move_word_fwd(&mut ed.buffer, true, count, &ed.settings.iskeyword);
3617            ed.push_buffer_cursor_to_textarea();
3618        }
3619        Motion::BigWordBack => {
3620            crate::motions::move_word_back(&mut ed.buffer, true, count, &ed.settings.iskeyword);
3621            ed.push_buffer_cursor_to_textarea();
3622        }
3623        Motion::BigWordEnd => {
3624            crate::motions::move_word_end(&mut ed.buffer, true, count, &ed.settings.iskeyword);
3625            ed.push_buffer_cursor_to_textarea();
3626        }
3627        Motion::WordEndBack => {
3628            crate::motions::move_word_end_back(
3629                &mut ed.buffer,
3630                false,
3631                count,
3632                &ed.settings.iskeyword,
3633            );
3634            ed.push_buffer_cursor_to_textarea();
3635        }
3636        Motion::BigWordEndBack => {
3637            crate::motions::move_word_end_back(&mut ed.buffer, true, count, &ed.settings.iskeyword);
3638            ed.push_buffer_cursor_to_textarea();
3639        }
3640        Motion::LineStart => {
3641            crate::motions::move_line_start(&mut ed.buffer);
3642            ed.push_buffer_cursor_to_textarea();
3643        }
3644        Motion::FirstNonBlank => {
3645            crate::motions::move_first_non_blank(&mut ed.buffer);
3646            ed.push_buffer_cursor_to_textarea();
3647        }
3648        Motion::LineEnd => {
3649            // Vim normal-mode `$` lands on the last char, not one past it.
3650            crate::motions::move_line_end(&mut ed.buffer);
3651            ed.push_buffer_cursor_to_textarea();
3652        }
3653        Motion::FileTop => {
3654            // `count gg` jumps to line `count` (first non-blank);
3655            // bare `gg` lands at the top.
3656            if count > 1 {
3657                crate::motions::move_bottom(&mut ed.buffer, count);
3658            } else {
3659                crate::motions::move_top(&mut ed.buffer);
3660            }
3661            ed.push_buffer_cursor_to_textarea();
3662        }
3663        Motion::FileBottom => {
3664            // `count G` jumps to line `count`; bare `G` lands at
3665            // the buffer bottom (`Buffer::move_bottom(0)`).
3666            if count > 1 {
3667                crate::motions::move_bottom(&mut ed.buffer, count);
3668            } else {
3669                crate::motions::move_bottom(&mut ed.buffer, 0);
3670            }
3671            ed.push_buffer_cursor_to_textarea();
3672        }
3673        Motion::Find { ch, forward, till } => {
3674            for _ in 0..count {
3675                if !find_char_on_line(ed, *ch, *forward, *till) {
3676                    break;
3677                }
3678            }
3679        }
3680        Motion::FindRepeat { .. } => {} // already resolved upstream
3681        Motion::MatchBracket => {
3682            let _ = matching_bracket(ed);
3683        }
3684        Motion::UnmatchedBracket { forward, open } => {
3685            goto_unmatched_bracket(ed, *forward, *open, count);
3686        }
3687        Motion::WordAtCursor {
3688            forward,
3689            whole_word,
3690        } => {
3691            word_at_cursor_search(ed, *forward, *whole_word, count);
3692        }
3693        Motion::SearchNext { reverse } => {
3694            // Re-push the last query so the buffer's search state is
3695            // correct even if the host happened to clear it (e.g. while
3696            // a Visual mode draw was in progress).
3697            if let Some(pattern) = ed.vim.last_search.clone() {
3698                ed.push_search_pattern(&pattern);
3699            }
3700            if ed.search_state().pattern.is_none() {
3701                return;
3702            }
3703            // `n` repeats the last search in its committed direction;
3704            // `N` inverts. So a `?` search makes `n` walk backward and
3705            // `N` walk forward.
3706            let forward = ed.vim.last_search_forward != *reverse;
3707            for _ in 0..count.max(1) {
3708                if forward {
3709                    ed.search_advance_forward(true);
3710                } else {
3711                    ed.search_advance_backward(true);
3712                }
3713            }
3714            ed.push_buffer_cursor_to_textarea();
3715        }
3716        Motion::ViewportTop => {
3717            let v = *ed.host().viewport();
3718            crate::motions::move_viewport_top(&mut ed.buffer, &v, count.saturating_sub(1));
3719            ed.push_buffer_cursor_to_textarea();
3720        }
3721        Motion::ViewportMiddle => {
3722            let v = *ed.host().viewport();
3723            crate::motions::move_viewport_middle(&mut ed.buffer, &v);
3724            ed.push_buffer_cursor_to_textarea();
3725        }
3726        Motion::ViewportBottom => {
3727            let v = *ed.host().viewport();
3728            crate::motions::move_viewport_bottom(&mut ed.buffer, &v, count.saturating_sub(1));
3729            ed.push_buffer_cursor_to_textarea();
3730        }
3731        Motion::LastNonBlank => {
3732            crate::motions::move_last_non_blank(&mut ed.buffer);
3733            ed.push_buffer_cursor_to_textarea();
3734        }
3735        Motion::LineMiddle => {
3736            let row = ed.cursor().0;
3737            let line_chars = buf_line_chars(&ed.buffer, row);
3738            // Vim's `gM`: column = floor(chars / 2). Empty / single-char
3739            // lines stay at col 0.
3740            let target = line_chars / 2;
3741            ed.jump_cursor(row, target);
3742        }
3743        Motion::ScreenLineMiddle => {
3744            // Vim's `gm`: middle of the *screen* line = column
3745            // `viewport_width / 2`, clamped to the last char of the line.
3746            let row = ed.cursor().0;
3747            let width = ed.host().viewport().width as usize;
3748            let last = buf_line_chars(&ed.buffer, row).saturating_sub(1);
3749            let target = (width / 2).min(last);
3750            ed.jump_cursor(row, target);
3751        }
3752        Motion::ParagraphPrev => {
3753            crate::motions::move_paragraph_prev(&mut ed.buffer, count);
3754            ed.push_buffer_cursor_to_textarea();
3755        }
3756        Motion::ParagraphNext => {
3757            crate::motions::move_paragraph_next(&mut ed.buffer, count);
3758            ed.push_buffer_cursor_to_textarea();
3759        }
3760        Motion::SentencePrev => {
3761            for _ in 0..count.max(1) {
3762                if let Some((row, col)) = sentence_boundary(ed, false) {
3763                    ed.jump_cursor(row, col);
3764                }
3765            }
3766        }
3767        Motion::SentenceNext => {
3768            for _ in 0..count.max(1) {
3769                if let Some((row, col)) = sentence_boundary(ed, true) {
3770                    ed.jump_cursor(row, col);
3771                }
3772            }
3773        }
3774        Motion::SectionBackward => {
3775            crate::motions::move_section_backward(&mut ed.buffer, count);
3776            ed.push_buffer_cursor_to_textarea();
3777        }
3778        Motion::SectionForward => {
3779            crate::motions::move_section_forward(&mut ed.buffer, count);
3780            ed.push_buffer_cursor_to_textarea();
3781        }
3782        Motion::SectionEndBackward => {
3783            crate::motions::move_section_end_backward(&mut ed.buffer, count);
3784            ed.push_buffer_cursor_to_textarea();
3785        }
3786        Motion::SectionEndForward => {
3787            crate::motions::move_section_end_forward(&mut ed.buffer, count);
3788            ed.push_buffer_cursor_to_textarea();
3789        }
3790        Motion::FirstNonBlankNextLine => {
3791            crate::motions::move_first_non_blank_next_line(&mut ed.buffer, count);
3792            ed.push_buffer_cursor_to_textarea();
3793        }
3794        Motion::FirstNonBlankPrevLine => {
3795            crate::motions::move_first_non_blank_prev_line(&mut ed.buffer, count);
3796            ed.push_buffer_cursor_to_textarea();
3797        }
3798        Motion::FirstNonBlankLine => {
3799            crate::motions::move_first_non_blank_line(&mut ed.buffer, count);
3800            ed.push_buffer_cursor_to_textarea();
3801        }
3802        Motion::GotoColumn => {
3803            crate::motions::move_goto_column(&mut ed.buffer, count);
3804            ed.push_buffer_cursor_to_textarea();
3805        }
3806    }
3807}
3808
3809fn move_first_non_whitespace<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
3810    // Some call sites invoke this right after `dd` / `<<` / `>>` etc
3811    // mutates the textarea content, so the migration buffer hasn't
3812    // seen the new lines OR new cursor yet. Mirror the full content
3813    // across before delegating, then push the result back so the
3814    // textarea reflects the resolved column too.
3815    ed.sync_buffer_content_from_textarea();
3816    crate::motions::move_first_non_blank(&mut ed.buffer);
3817    ed.push_buffer_cursor_to_textarea();
3818}
3819
3820fn find_char_on_line<H: crate::types::Host>(
3821    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3822    ch: char,
3823    forward: bool,
3824    till: bool,
3825) -> bool {
3826    let moved = crate::motions::find_char_on_line(&mut ed.buffer, ch, forward, till);
3827    if moved {
3828        ed.push_buffer_cursor_to_textarea();
3829    }
3830    moved
3831}
3832
3833fn matching_bracket<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) -> bool {
3834    let moved = crate::motions::match_bracket(&mut ed.buffer);
3835    if moved {
3836        ed.push_buffer_cursor_to_textarea();
3837    }
3838    moved
3839}
3840
3841/// `[(` / `])` / `[{` / `]}` — move to the `count`-th previous (`forward =
3842/// false`) / next (`forward = true`) unmatched bracket of the kind given by
3843/// `open` (`(` or `{`). Balanced inner pairs are skipped via a depth counter.
3844fn goto_unmatched_bracket<H: crate::types::Host>(
3845    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3846    forward: bool,
3847    open: char,
3848    count: usize,
3849) {
3850    let close = match open {
3851        '(' => ')',
3852        '{' => '}',
3853        _ => return,
3854    };
3855    let cursor = buf_cursor_pos(&ed.buffer);
3856    let rows = buf_row_count(&ed.buffer);
3857    let target = count.max(1);
3858    let mut found = 0usize;
3859    let mut depth = 0i32;
3860
3861    if forward {
3862        let mut r = cursor.row;
3863        let mut from_col = cursor.col + 1;
3864        while r < rows {
3865            let line: Vec<char> = buf_line(&ed.buffer, r)
3866                .unwrap_or_default()
3867                .chars()
3868                .collect();
3869            let mut ci = from_col;
3870            while ci < line.len() {
3871                let ch = line[ci];
3872                if ch == open {
3873                    depth += 1;
3874                } else if ch == close {
3875                    if depth == 0 {
3876                        found += 1;
3877                        if found == target {
3878                            buf_set_cursor_rc(&mut ed.buffer, r, ci);
3879                            ed.push_buffer_cursor_to_textarea();
3880                            return;
3881                        }
3882                    } else {
3883                        depth -= 1;
3884                    }
3885                }
3886                ci += 1;
3887            }
3888            r += 1;
3889            from_col = 0;
3890        }
3891    } else {
3892        let mut r = cursor.row as isize;
3893        // First row scans from the column left of the cursor; earlier rows from
3894        // their last column (`isize::MAX` clamps to `len - 1`).
3895        let mut from_col = cursor.col as isize - 1;
3896        while r >= 0 {
3897            let line: Vec<char> = buf_line(&ed.buffer, r as usize)
3898                .unwrap_or_default()
3899                .chars()
3900                .collect();
3901            let mut ci = from_col.min(line.len() as isize - 1);
3902            while ci >= 0 {
3903                let ch = line[ci as usize];
3904                if ch == close {
3905                    depth += 1;
3906                } else if ch == open {
3907                    if depth == 0 {
3908                        found += 1;
3909                        if found == target {
3910                            buf_set_cursor_rc(&mut ed.buffer, r as usize, ci as usize);
3911                            ed.push_buffer_cursor_to_textarea();
3912                            return;
3913                        }
3914                    } else {
3915                        depth -= 1;
3916                    }
3917                }
3918                ci -= 1;
3919            }
3920            r -= 1;
3921            from_col = isize::MAX;
3922        }
3923    }
3924}
3925
3926fn word_at_cursor_search<H: crate::types::Host>(
3927    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3928    forward: bool,
3929    whole_word: bool,
3930    count: usize,
3931) {
3932    let (row, col) = ed.cursor();
3933    let line: String = buf_line(&ed.buffer, row).unwrap_or_default();
3934    let chars: Vec<char> = line.chars().collect();
3935    if chars.is_empty() {
3936        return;
3937    }
3938    // Expand around cursor to a word boundary.
3939    let spec = ed.settings().iskeyword.clone();
3940    let is_word = |c: char| is_keyword_char(c, &spec);
3941    let mut start = col.min(chars.len().saturating_sub(1));
3942    while start > 0 && is_word(chars[start - 1]) {
3943        start -= 1;
3944    }
3945    let mut end = start;
3946    while end < chars.len() && is_word(chars[end]) {
3947        end += 1;
3948    }
3949    if end <= start {
3950        return;
3951    }
3952    let word: String = chars[start..end].iter().collect();
3953    let escaped = regex_escape(&word);
3954    let pattern = if whole_word {
3955        format!(r"\b{escaped}\b")
3956    } else {
3957        escaped
3958    };
3959    ed.push_search_pattern(&pattern);
3960    if ed.search_state().pattern.is_none() {
3961        return;
3962    }
3963    // Remember the query so `n` / `N` keep working after the jump.
3964    ed.vim.last_search = Some(pattern);
3965    ed.vim.last_search_forward = forward;
3966    for _ in 0..count.max(1) {
3967        if forward {
3968            ed.search_advance_forward(true);
3969        } else {
3970            ed.search_advance_backward(true);
3971        }
3972    }
3973    ed.push_buffer_cursor_to_textarea();
3974}
3975
3976fn regex_escape(s: &str) -> String {
3977    let mut out = String::with_capacity(s.len());
3978    for c in s.chars() {
3979        if matches!(
3980            c,
3981            '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\'
3982        ) {
3983            out.push('\\');
3984        }
3985        out.push(c);
3986    }
3987    out
3988}
3989
3990// ─── Operator application ──────────────────────────────────────────────────
3991
3992/// Public(crate) entry: apply operator over the motion identified by a raw
3993/// char key. Called by `Editor::apply_op_motion` (the public controller API)
3994/// so the hjkl-vim pending-state reducer can dispatch `ApplyOpMotion` without
3995/// re-entering the FSM.
3996///
3997/// Applies standard vim quirks:
3998/// - `cw` / `cW` → `ce` / `cE`
3999/// - `FindRepeat` → resolves against `last_find`
4000/// - Updates `last_find` and `last_change` per existing conventions.
4001///
4002/// No-op when `motion_key` does not produce a known motion.
4003pub(crate) fn apply_op_motion_key<H: crate::types::Host>(
4004    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4005    op: Operator,
4006    motion_key: char,
4007    total_count: usize,
4008) {
4009    let input = Input {
4010        key: Key::Char(motion_key),
4011        ctrl: false,
4012        alt: false,
4013        shift: false,
4014    };
4015    let Some(motion) = parse_motion(&input) else {
4016        return;
4017    };
4018    let motion = match motion {
4019        Motion::FindRepeat { reverse } => match ed.vim.last_find {
4020            Some((ch, forward, till)) => Motion::Find {
4021                ch,
4022                forward: if reverse { !forward } else { forward },
4023                till,
4024            },
4025            None => return,
4026        },
4027        // Vim quirk: `cw` / `cW` → `ce` / `cE`.
4028        Motion::WordFwd if op == Operator::Change => Motion::WordEnd,
4029        Motion::BigWordFwd if op == Operator::Change => Motion::BigWordEnd,
4030        m => m,
4031    };
4032    apply_op_with_motion(ed, op, &motion, total_count);
4033    if let Motion::Find { ch, forward, till } = &motion {
4034        ed.vim.last_find = Some((*ch, *forward, *till));
4035    }
4036    if !ed.vim.replaying && op_is_change(op) {
4037        ed.vim.last_change = Some(LastChange::OpMotion {
4038            op,
4039            motion,
4040            count: total_count,
4041            inserted: None,
4042        });
4043    }
4044}
4045
4046/// Public(crate) entry: apply doubled-letter line op (`dd`/`yy`/`cc`/`>>`/`<<`/`gcc`).
4047/// Called by `Editor::apply_op_double` (the public controller API).
4048pub(crate) fn apply_op_double<H: crate::types::Host>(
4049    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4050    op: Operator,
4051    total_count: usize,
4052) {
4053    if op == Operator::Comment {
4054        // `gcc` / `{N}gcc` — toggle comment on `total_count` lines starting at cursor.
4055        let row = buf_cursor_pos(&ed.buffer).row;
4056        let end_row = (row + total_count.max(1) - 1).min(ed.buffer.row_count().saturating_sub(1));
4057        ed.toggle_comment_range(row, end_row);
4058        ed.vim.mode = Mode::Normal;
4059        if !ed.vim.replaying {
4060            ed.vim.last_change = Some(LastChange::LineOp {
4061                op,
4062                count: total_count,
4063                inserted: None,
4064            });
4065        }
4066        return;
4067    }
4068    execute_line_op(ed, op, total_count);
4069    if !ed.vim.replaying {
4070        ed.vim.last_change = Some(LastChange::LineOp {
4071            op,
4072            count: total_count,
4073            inserted: None,
4074        });
4075    }
4076}
4077
4078/// Compute the `gn` / `gN` target match as a `(start, end_inclusive)` pair.
4079/// When the cursor sits inside a match, that match is the target; otherwise the
4080/// next match (forward) or previous match (backward) is used. Returns `None`
4081/// when there is no pattern or no match remains.
4082fn gn_find_range<H: crate::types::Host>(
4083    ed: &Editor<hjkl_buffer::Buffer, H>,
4084    re: &regex::Regex,
4085    forward: bool,
4086) -> Option<(crate::types::Pos, crate::types::Pos)> {
4087    use crate::types::{Cursor, Pos, Search};
4088    let cursor = Cursor::cursor(&ed.buffer);
4089    let contains =
4090        Search::find_prev(&ed.buffer, cursor, re).filter(|m| m.start <= cursor && cursor < m.end);
4091    let range = if let Some(m) = contains {
4092        m
4093    } else if forward {
4094        Search::find_next(&ed.buffer, cursor, re)?
4095    } else {
4096        Search::find_prev(&ed.buffer, cursor, re)?
4097    };
4098    let end_incl = if range.end.col > 0 {
4099        Pos::new(range.end.line, range.end.col - 1)
4100    } else {
4101        range.end
4102    };
4103    Some((range.start, end_incl))
4104}
4105
4106/// `gn` / `gN` — operate on (or select) the search match. `op = None` enters
4107/// Visual mode with the match selected; `Some(op)` applies the operator to the
4108/// match as a charwise inclusive range. Records `LastChange::GnOp` so `cgn` /
4109/// `dgn` are `.`-repeatable.
4110pub(crate) fn gn_operate<H: crate::types::Host>(
4111    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4112    op: Option<Operator>,
4113    forward: bool,
4114    count: usize,
4115) {
4116    use crate::types::{Cursor, Pos};
4117    // Make sure the compiled pattern reflects the last `/` or `*` search.
4118    if let Some(p) = ed.vim.last_search.clone() {
4119        ed.push_search_pattern(&p);
4120    }
4121    let Some(re) = ed.search_state().pattern.clone() else {
4122        return;
4123    };
4124    ed.sync_buffer_content_from_textarea();
4125
4126    let Some(mut range) = gn_find_range(ed, &re, forward) else {
4127        return;
4128    };
4129    // `[count]gn` walks to the count-th match.
4130    for _ in 1..count.max(1) {
4131        let past = Pos::new(range.1.line, range.1.col + 1);
4132        Cursor::set_cursor(&mut ed.buffer, past);
4133        match gn_find_range(ed, &re, forward) {
4134            Some(r) => range = r,
4135            None => break,
4136        }
4137    }
4138    let start_t = (range.0.line as usize, range.0.col as usize);
4139    let end_t = (range.1.line as usize, range.1.col as usize);
4140
4141    match op {
4142        None => {
4143            // Bare `gn` — select the match in Visual mode.
4144            ed.vim.visual_anchor = start_t;
4145            buf_set_cursor_rc(&mut ed.buffer, end_t.0, end_t.1);
4146            ed.vim.mode = Mode::Visual;
4147            ed.vim.current_mode = crate::VimMode::Visual;
4148            ed.push_buffer_cursor_to_textarea();
4149        }
4150        Some(Operator::Delete) => {
4151            ed.push_undo();
4152            cut_vim_range(ed, start_t, end_t, RangeKind::Inclusive);
4153            // Deleting at the line end can leave the cursor one past the last
4154            // char; vim clamps it back onto the line.
4155            clamp_cursor_to_normal_mode(ed);
4156            ed.push_buffer_cursor_to_textarea();
4157            if !ed.vim.replaying {
4158                ed.vim.last_change = Some(LastChange::GnOp {
4159                    op: Operator::Delete,
4160                    forward,
4161                    inserted: None,
4162                });
4163            }
4164        }
4165        Some(Operator::Change) => {
4166            ed.push_undo();
4167            ed.vim.change_mark_start = Some(start_t);
4168            cut_vim_range(ed, start_t, end_t, RangeKind::Inclusive);
4169            if !ed.vim.replaying {
4170                ed.vim.last_change = Some(LastChange::GnOp {
4171                    op: Operator::Change,
4172                    forward,
4173                    inserted: None,
4174                });
4175            }
4176            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
4177        }
4178        Some(Operator::Yank) => {
4179            let text = read_vim_range(ed, start_t, end_t, RangeKind::Inclusive);
4180            if !text.is_empty() {
4181                ed.record_yank_to_host(text.clone());
4182                ed.record_yank(text, false);
4183            }
4184            buf_set_cursor_rc(&mut ed.buffer, start_t.0, start_t.1);
4185            ed.push_buffer_cursor_to_textarea();
4186        }
4187        Some(other @ (Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase)) => {
4188            // Case op over a gn match: apply as a charwise op over the
4189            // inclusive range.
4190            ed.push_undo();
4191            apply_case_op_to_selection(ed, other, start_t, end_t, RangeKind::Inclusive);
4192        }
4193        Some(_) => {}
4194    }
4195}
4196
4197/// Shared implementation: apply operator over a g-chord motion or case-op
4198/// linewise form. Called by `Editor::apply_op_g` (the public controller API)
4199/// so the hjkl-vim reducer can dispatch `ApplyOpG` without re-entering the FSM.
4200///
4201/// - If `op` is Uppercase/Lowercase/ToggleCase and `ch` matches the op's char
4202///   (`U`/`u`/`~`): executes the line op and updates `last_change`.
4203/// - `n` / `N` operate on the search match (`dgn` / `cgn`).
4204/// - Otherwise, maps `ch` to a motion (`g`→FileTop, `e`→WordEndBack,
4205///   `E`→BigWordEndBack, `j`→ScreenDown, `k`→ScreenUp) and applies. Unknown
4206///   chars are silently ignored (no-op), matching the engine FSM's behaviour.
4207pub(crate) fn apply_op_g_inner<H: crate::types::Host>(
4208    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4209    op: Operator,
4210    ch: char,
4211    total_count: usize,
4212) {
4213    // Case-op linewise form: `gUgU`, `gugu`, `g~g~`, `g?g?` — same effect as
4214    // `gUU` / `guu` / `g~~` / `g??`.
4215    if matches!(
4216        op,
4217        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13
4218    ) {
4219        let op_char = match op {
4220            Operator::Uppercase => 'U',
4221            Operator::Lowercase => 'u',
4222            Operator::ToggleCase => '~',
4223            Operator::Rot13 => '?',
4224            _ => unreachable!(),
4225        };
4226        if ch == op_char {
4227            execute_line_op(ed, op, total_count);
4228            if !ed.vim.replaying {
4229                ed.vim.last_change = Some(LastChange::LineOp {
4230                    op,
4231                    count: total_count,
4232                    inserted: None,
4233                });
4234            }
4235            return;
4236        }
4237    }
4238    // `dgn` / `cgn` / `ygn` (and `gN` forms) — operate on the search match.
4239    if ch == 'n' || ch == 'N' {
4240        gn_operate(ed, Some(op), ch == 'n', total_count);
4241        return;
4242    }
4243    let motion = match ch {
4244        'g' => Motion::FileTop,
4245        'e' => Motion::WordEndBack,
4246        'E' => Motion::BigWordEndBack,
4247        'j' => Motion::ScreenDown,
4248        'k' => Motion::ScreenUp,
4249        _ => return, // Unknown char — no-op.
4250    };
4251    apply_op_with_motion(ed, op, &motion, total_count);
4252    if !ed.vim.replaying && op_is_change(op) {
4253        ed.vim.last_change = Some(LastChange::OpMotion {
4254            op,
4255            motion,
4256            count: total_count,
4257            inserted: None,
4258        });
4259    }
4260}
4261
4262/// Public(crate) entry point for bare `g<x>`. Applies the g-chord effect
4263/// given the char `ch` and pre-captured `count`. Called by `Editor::after_g`
4264/// (the public controller API) so the hjkl-vim pending-state reducer can
4265/// dispatch `AfterGChord` without re-entering the FSM.
4266pub(crate) fn apply_after_g<H: crate::types::Host>(
4267    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4268    ch: char,
4269    count: usize,
4270) {
4271    match ch {
4272        'g' => {
4273            // gg — top / jump to line count.
4274            let pre = ed.cursor();
4275            if count > 1 {
4276                ed.jump_cursor(count - 1, 0);
4277            } else {
4278                ed.jump_cursor(0, 0);
4279            }
4280            move_first_non_whitespace(ed);
4281            // Update sticky_col to the first-non-blank column so j/k after
4282            // gg aim for the correct column per vim semantics.
4283            ed.sticky_col = Some(ed.cursor().1);
4284            if ed.cursor() != pre {
4285                ed.push_jump(pre);
4286            }
4287        }
4288        'e' => execute_motion(ed, Motion::WordEndBack, count),
4289        'E' => execute_motion(ed, Motion::BigWordEndBack, count),
4290        // `g_` — last non-blank on the line.
4291        '_' => execute_motion(ed, Motion::LastNonBlank, count),
4292        // `gM` — middle char column of the current line.
4293        'M' => execute_motion(ed, Motion::LineMiddle, count),
4294        // `gm` — middle of the screen line (viewport_width/2, clamped to EOL).
4295        'm' => execute_motion(ed, Motion::ScreenLineMiddle, count),
4296        // `gv` — re-enter the last visual selection.
4297        // Phase 6.6a: drive through the public Editor API.
4298        'v' => ed.reenter_last_visual(),
4299        // `gj` / `gk` — display-line down / up. Walks one screen
4300        // segment at a time under `:set wrap`; falls back to `j`/`k`
4301        // when wrap is off (Buffer::move_screen_* handles the branch).
4302        'j' => execute_motion(ed, Motion::ScreenDown, count),
4303        'k' => execute_motion(ed, Motion::ScreenUp, count),
4304        // Case operators: `gU` / `gu` / `g~`. Enter operator-pending
4305        // so the next input is treated as the motion / text object /
4306        // shorthand double (`gUU`, `guu`, `g~~`).
4307        'U' => {
4308            ed.vim.pending = Pending::Op {
4309                op: Operator::Uppercase,
4310                count1: count,
4311            };
4312        }
4313        'u' => {
4314            ed.vim.pending = Pending::Op {
4315                op: Operator::Lowercase,
4316                count1: count,
4317            };
4318        }
4319        '~' => {
4320            ed.vim.pending = Pending::Op {
4321                op: Operator::ToggleCase,
4322                count1: count,
4323            };
4324        }
4325        '?' => {
4326            // `g?{motion}` — ROT13 operator (`g??` / `g?g?` doubled).
4327            ed.vim.pending = Pending::Op {
4328                op: Operator::Rot13,
4329                count1: count,
4330            };
4331        }
4332        'q' => {
4333            // `gq{motion}` — text reflow operator. Subsequent motion
4334            // / textobj rides the same operator pipeline.
4335            ed.vim.pending = Pending::Op {
4336                op: Operator::Reflow,
4337                count1: count,
4338            };
4339        }
4340        'w' => {
4341            // `gw{motion}` — same reflow as `gq` but cursor stays at
4342            // its pre-reflow position (clamped to new EOL if shorter).
4343            ed.vim.pending = Pending::Op {
4344                op: Operator::ReflowKeepCursor,
4345                count1: count,
4346            };
4347        }
4348        'J' => {
4349            // `gJ` — join line below without inserting a space. `[count]gJ`
4350            // joins `count` lines (`count - 1` joins), like `J`.
4351            let joins = count.max(2) - 1;
4352            for _ in 0..joins {
4353                ed.push_undo();
4354                join_line_raw(ed);
4355            }
4356            if !ed.vim.replaying {
4357                ed.vim.last_change = Some(LastChange::JoinLine { count: joins });
4358            }
4359        }
4360        'd' => {
4361            // `gd` — goto definition. hjkl-engine doesn't run an LSP
4362            // itself; raise an intent the host drains and routes to
4363            // `sqls`. The cursor stays put here — the host moves it
4364            // once it has the target location.
4365            ed.pending_lsp = Some(crate::editor::LspIntent::GotoDefinition);
4366        }
4367        // `gi` — go to last-insert position and re-enter insert mode.
4368        // Matches vim's `:h gi`: moves to the `'^` mark position (the
4369        // cursor where insert mode was last active, before Esc step-back)
4370        // and enters insert mode there.
4371        'i' => {
4372            if let Some((row, col)) = ed.vim.last_insert_pos {
4373                ed.jump_cursor(row, col);
4374            }
4375            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::I));
4376        }
4377        // `gc` — enter operator-pending for the comment-toggle operator.
4378        // `gcc` (doubled 'c') is the line-wise form; `gc{motion}` is the
4379        // motion form. The operator is Comment — the app layer (or the
4380        // doubled-char path in handle_after_op) calls toggle_comment_range.
4381        'c' => {
4382            ed.vim.pending = Pending::Op {
4383                op: Operator::Comment,
4384                count1: count,
4385            };
4386        }
4387        // `gp` / `gP` — paste like `p`/`P` but leave the cursor just after
4388        // the pasted text.
4389        'p' => paste_bridge(ed, false, count.max(1), true, false),
4390        'P' => paste_bridge(ed, true, count.max(1), true, false),
4391        // `gn` / `gN` — select the next / previous search match in Visual mode.
4392        'n' => gn_operate(ed, None, true, count.max(1)),
4393        'N' => gn_operate(ed, None, false, count.max(1)),
4394        // `g;` / `g,` — walk the change list. `g;` toward older
4395        // entries, `g,` toward newer.
4396        ';' => walk_change_list(ed, -1, count.max(1)),
4397        ',' => walk_change_list(ed, 1, count.max(1)),
4398        // `g*` / `g#` — like `*` / `#` but match substrings (no `\b`
4399        // boundary anchors), so the cursor on `foo` finds it inside
4400        // `foobar` too.
4401        '*' => execute_motion(
4402            ed,
4403            Motion::WordAtCursor {
4404                forward: true,
4405                whole_word: false,
4406            },
4407            count,
4408        ),
4409        '#' => execute_motion(
4410            ed,
4411            Motion::WordAtCursor {
4412                forward: false,
4413                whole_word: false,
4414            },
4415            count,
4416        ),
4417        // `g&` — repeat last `:s` over the whole buffer (1,$), keeping all
4418        // original flags. Equivalent to `:%s//~/&` in vim.
4419        '&' => {
4420            let cmd = match ed.vim.last_substitute.clone() {
4421                Some(c) => c,
4422                None => {
4423                    // No prior substitute — mirror the `:&` error path; do
4424                    // nothing to the buffer (the host's status line will show
4425                    // the pending error if wired; for headless / test hosts
4426                    // we simply return silently).
4427                    return;
4428                }
4429            };
4430            let last_row = buf_row_count(&ed.buffer).saturating_sub(1) as u32;
4431            let r = 0u32..=last_row;
4432            // apply_substitute moves cursor to last changed line and pushes
4433            // one undo snapshot — same semantics as `:&&` / `:%s//~/&`.
4434            let _ = crate::substitute::apply_substitute(ed, &cmd, r);
4435            // Update stored substitute so subsequent `g&` sees the same cmd.
4436            // (apply_substitute doesn't call set_last_substitute itself.)
4437            ed.vim.last_substitute = Some(cmd);
4438        }
4439        _ => {}
4440    }
4441}
4442
4443/// Normal-mode `&` — repeat the last `:s` on the current line, dropping the
4444/// previous flags (vim: `&` ≡ `:s` with no flags). `g&` keeps flags + whole
4445/// buffer; this is the single-line, flag-less form.
4446pub(crate) fn ampersand_repeat<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
4447    let Some(mut cmd) = ed.vim.last_substitute.clone() else {
4448        return;
4449    };
4450    cmd.flags = crate::substitute::SubstFlags::default();
4451    let row = buf_cursor_pos(&ed.buffer).row as u32;
4452    let _ = crate::substitute::apply_substitute(ed, &cmd, row..=row);
4453}
4454
4455/// Public(crate) entry point for bare `z<x>`. Applies the z-chord effect
4456/// given the char `ch` and pre-captured `count`. Called by `Editor::after_z`
4457/// (the public controller API) so the hjkl-vim pending-state reducer can
4458/// dispatch `AfterZChord` without re-entering the engine FSM.
4459pub(crate) fn apply_after_z<H: crate::types::Host>(
4460    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4461    ch: char,
4462    count: usize,
4463) {
4464    use crate::editor::CursorScrollTarget;
4465    let row = ed.cursor().0;
4466    match ch {
4467        'z' => {
4468            ed.scroll_cursor_to(CursorScrollTarget::Center);
4469            ed.vim.viewport_pinned = true;
4470            ed.vim.scroll_anim_hint = true;
4471        }
4472        't' => {
4473            ed.scroll_cursor_to(CursorScrollTarget::Top);
4474            ed.vim.viewport_pinned = true;
4475            ed.vim.scroll_anim_hint = true;
4476        }
4477        'b' => {
4478            ed.scroll_cursor_to(CursorScrollTarget::Bottom);
4479            ed.vim.viewport_pinned = true;
4480            ed.vim.scroll_anim_hint = true;
4481        }
4482        // Folds — operate on the fold under the cursor (or the
4483        // whole buffer for `R` / `M`). Routed through
4484        // [`Editor::apply_fold_op`] (0.0.38 Patch C-δ.4) so the host
4485        // can observe / veto each op via [`Editor::take_fold_ops`].
4486        'o' => {
4487            ed.apply_fold_op(crate::types::FoldOp::OpenAt(row));
4488        }
4489        'c' => {
4490            ed.apply_fold_op(crate::types::FoldOp::CloseAt(row));
4491        }
4492        'a' => {
4493            ed.apply_fold_op(crate::types::FoldOp::ToggleAt(row));
4494        }
4495        'R' => {
4496            ed.apply_fold_op(crate::types::FoldOp::OpenAll);
4497        }
4498        'M' => {
4499            ed.apply_fold_op(crate::types::FoldOp::CloseAll);
4500        }
4501        'E' => {
4502            ed.apply_fold_op(crate::types::FoldOp::ClearAll);
4503        }
4504        'd' => {
4505            ed.apply_fold_op(crate::types::FoldOp::RemoveAt(row));
4506        }
4507        'f' => {
4508            if matches!(
4509                ed.vim.mode,
4510                Mode::Visual | Mode::VisualLine | Mode::VisualBlock
4511            ) {
4512                // `zf` over a Visual selection creates a fold spanning
4513                // anchor → cursor.
4514                let anchor_row = match ed.vim.mode {
4515                    Mode::VisualLine => ed.vim.visual_line_anchor,
4516                    Mode::VisualBlock => ed.vim.block_anchor.0,
4517                    _ => ed.vim.visual_anchor.0,
4518                };
4519                let cur = ed.cursor().0;
4520                let top = anchor_row.min(cur);
4521                let bot = anchor_row.max(cur);
4522                ed.apply_fold_op(crate::types::FoldOp::Add {
4523                    start_row: top,
4524                    end_row: bot,
4525                    closed: true,
4526                });
4527                ed.vim.mode = Mode::Normal;
4528            } else {
4529                // `zf{motion}` / `zf{textobj}` — route through the
4530                // operator pipeline. `Operator::Fold` reuses every
4531                // motion / text-object / `g`-prefix branch the other
4532                // operators get.
4533                ed.vim.pending = Pending::Op {
4534                    op: Operator::Fold,
4535                    count1: count,
4536                };
4537            }
4538        }
4539        _ => {}
4540    }
4541}
4542
4543/// Public(crate) entry point for bare `f<x>` / `F<x>` / `t<x>` / `T<x>`.
4544/// Applies the motion and records `last_find` for `;` / `,` repeat.
4545/// Called by `Editor::find_char` (the public controller API) so the
4546/// hjkl-vim pending-state reducer can dispatch `FindChar` without
4547/// re-entering the FSM.
4548pub(crate) fn apply_find_char<H: crate::types::Host>(
4549    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4550    ch: char,
4551    forward: bool,
4552    till: bool,
4553    count: usize,
4554) {
4555    execute_motion(ed, Motion::Find { ch, forward, till }, count.max(1));
4556    ed.vim.last_find = Some((ch, forward, till));
4557    ed.vim.last_horizontal_motion = LastHorizontalMotion::FindChar;
4558}
4559
4560// ─── Sneak motion ──────────────────────────────────────────────────────────
4561
4562/// Scan the buffer from the current cursor position for the `count`-th
4563/// occurrence of the two-char digraph `(c1, c2)`.
4564///
4565/// - `forward=true` → scan downward (rows) and rightward (cols) past cursor.
4566/// - `forward=false` → scan upward and leftward.
4567///
4568/// When a match is found the cursor jumps to the first char of the digraph.
4569/// `last_sneak` and `last_horizontal_motion` are updated so `;`/`,` repeat.
4570/// No-op (cursor unchanged) when no match exists.
4571pub(crate) fn apply_sneak<H: crate::types::Host>(
4572    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4573    c1: char,
4574    c2: char,
4575    forward: bool,
4576    count: usize,
4577) {
4578    let count = count.max(1);
4579    let (start_row, start_col) = ed.cursor();
4580    let row_count = buf_row_count(&ed.buffer);
4581
4582    let result = if forward {
4583        sneak_scan_forward(ed, start_row, start_col, c1, c2, count)
4584    } else {
4585        sneak_scan_backward(ed, start_row, start_col, c1, c2, count)
4586    };
4587
4588    if let Some((row, col)) = result {
4589        buf_set_cursor_rc(&mut ed.buffer, row, col);
4590        ed.push_buffer_cursor_to_textarea();
4591        let _ = row_count; // suppress unused-variable warning
4592    }
4593
4594    ed.vim.last_sneak = Some(((c1, c2), forward));
4595    ed.vim.last_horizontal_motion = LastHorizontalMotion::Sneak;
4596}
4597
4598/// Scan forward from `(start_row, start_col)` (exclusive — start right after
4599/// cursor) for the `count`-th occurrence of `c1+c2`.
4600fn sneak_scan_forward<H: crate::types::Host>(
4601    ed: &Editor<hjkl_buffer::Buffer, H>,
4602    start_row: usize,
4603    start_col: usize,
4604    c1: char,
4605    c2: char,
4606    count: usize,
4607) -> Option<(usize, usize)> {
4608    let row_count = buf_row_count(&ed.buffer);
4609    let mut hits = 0usize;
4610    for row in start_row..row_count {
4611        let line = buf_line(&ed.buffer, row).unwrap_or_default();
4612        let chars: Vec<char> = line.chars().collect();
4613        // On the start row begin scanning one past the current column.
4614        let col_start = if row == start_row { start_col + 1 } else { 0 };
4615        if col_start + 1 > chars.len() {
4616            continue;
4617        }
4618        for col in col_start..chars.len().saturating_sub(1) {
4619            if chars[col] == c1 && chars[col + 1] == c2 {
4620                hits += 1;
4621                if hits == count {
4622                    return Some((row, col));
4623                }
4624            }
4625        }
4626    }
4627    None
4628}
4629
4630/// Scan backward from `(start_row, start_col)` (exclusive — start left of
4631/// cursor) for the `count`-th occurrence of `c1+c2`.
4632fn sneak_scan_backward<H: crate::types::Host>(
4633    ed: &Editor<hjkl_buffer::Buffer, H>,
4634    start_row: usize,
4635    start_col: usize,
4636    c1: char,
4637    c2: char,
4638    count: usize,
4639) -> Option<(usize, usize)> {
4640    let row_count = buf_row_count(&ed.buffer);
4641    let mut hits = 0usize;
4642    // Iterate rows from start_row down to 0.
4643    let rows_to_scan = (0..row_count).rev().skip(row_count - start_row - 1);
4644    for row in rows_to_scan {
4645        let line = buf_line(&ed.buffer, row).unwrap_or_default();
4646        let chars: Vec<char> = line.chars().collect();
4647        // On the start row end scanning one before the current column.
4648        let col_end = if row == start_row {
4649            start_col.saturating_sub(1)
4650        } else if chars.is_empty() {
4651            continue;
4652        } else {
4653            chars.len().saturating_sub(1)
4654        };
4655        if col_end == 0 {
4656            continue;
4657        }
4658        // Scan cols right-to-left from col_end-1 so we match c1 at col, c2 at col+1.
4659        for col in (0..col_end).rev() {
4660            if col + 1 < chars.len() && chars[col] == c1 && chars[col + 1] == c2 {
4661                hits += 1;
4662                if hits == count {
4663                    return Some((row, col));
4664                }
4665            }
4666        }
4667    }
4668    None
4669}
4670
4671/// Apply `op` over the sneak digraph range. Charwise exclusive from cursor up
4672/// to (but not including) the first char of the first match. This matches
4673/// vim-sneak's default `<Plug>Sneak_s` operator-pending behavior.
4674///
4675/// Example: buffer `"foo ab bar\n"`, cursor col 0, `dsab` → deletes `"foo "`
4676/// leaving `"ab bar\n"`.
4677pub(crate) fn apply_op_sneak<H: crate::types::Host>(
4678    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4679    op: Operator,
4680    c1: char,
4681    c2: char,
4682    forward: bool,
4683    total_count: usize,
4684) {
4685    let start = ed.cursor();
4686    let result = if forward {
4687        sneak_scan_forward(ed, start.0, start.1, c1, c2, total_count)
4688    } else {
4689        sneak_scan_backward(ed, start.0, start.1, c1, c2, total_count)
4690    };
4691    let Some(end) = result else {
4692        return;
4693    };
4694    // Charwise exclusive — land the virtual cursor at end, then use
4695    // Exclusive range kind (end position not included).
4696    ed.jump_cursor(end.0, end.1);
4697    let end_cur = ed.cursor();
4698    ed.jump_cursor(start.0, start.1);
4699    run_operator_over_range(ed, op, start, end_cur, RangeKind::Exclusive);
4700    ed.vim.last_sneak = Some(((c1, c2), forward));
4701    ed.vim.last_horizontal_motion = LastHorizontalMotion::Sneak;
4702    if !ed.vim.replaying && op_is_change(op) {
4703        // No dot-repeat motion variant for sneak ops (plugin behavior,
4704        // not vim-core); record as a Change/Delete line op as a
4705        // best-effort fallback so `.` at least does something.
4706    }
4707}
4708
4709/// Public(crate) entry: apply operator over a find motion (`df<x>` etc.).
4710/// Called by `Editor::apply_op_find` (the public controller API) so the
4711/// hjkl-vim `PendingState::OpFind` reducer can dispatch `ApplyOpFind` without
4712/// re-entering the FSM. `handle_op_find_target` now delegates here to avoid
4713/// logic duplication.
4714pub(crate) fn apply_op_find_motion<H: crate::types::Host>(
4715    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4716    op: Operator,
4717    ch: char,
4718    forward: bool,
4719    till: bool,
4720    total_count: usize,
4721) {
4722    let motion = Motion::Find { ch, forward, till };
4723    apply_op_with_motion(ed, op, &motion, total_count);
4724    ed.vim.last_find = Some((ch, forward, till));
4725    if !ed.vim.replaying && op_is_change(op) {
4726        ed.vim.last_change = Some(LastChange::OpMotion {
4727            op,
4728            motion,
4729            count: total_count,
4730            inserted: None,
4731        });
4732    }
4733}
4734
4735/// Shared implementation: map `ch` to `TextObject`, apply the operator, and
4736/// record `last_change`. Returns `false` when `ch` is not a known text-object
4737/// kind (caller should treat as a no-op). Called by `Editor::apply_op_text_obj`
4738/// (the public controller API) so hjkl-vim can dispatch without re-entering the FSM.
4739///
4740/// `_total_count` is accepted for API symmetry with `apply_op_find_motion` /
4741/// `apply_op_motion_key` but is currently unused — text objects don't repeat
4742/// in vim's current grammar. Kept for future-proofing.
4743pub(crate) fn apply_op_text_obj_inner<H: crate::types::Host>(
4744    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4745    op: Operator,
4746    ch: char,
4747    inner: bool,
4748    total_count: usize,
4749) -> bool {
4750    // `total_count` drives bracket text objects: `2di{` targets the Nth
4751    // enclosing pair. Non-bracket objects ignore it (vim does too).
4752    let obj = match ch {
4753        'w' => TextObject::Word { big: false },
4754        'W' => TextObject::Word { big: true },
4755        '"' | '\'' | '`' => TextObject::Quote(ch),
4756        '(' | ')' | 'b' => TextObject::Bracket('('),
4757        '[' | ']' => TextObject::Bracket('['),
4758        '{' | '}' | 'B' => TextObject::Bracket('{'),
4759        '<' | '>' => TextObject::Bracket('<'),
4760        'p' => TextObject::Paragraph,
4761        't' => TextObject::XmlTag,
4762        's' => TextObject::Sentence,
4763        _ => return false,
4764    };
4765    apply_op_with_text_object(ed, op, obj, inner, total_count.max(1));
4766    if !ed.vim.replaying && op_is_change(op) {
4767        ed.vim.last_change = Some(LastChange::OpTextObj {
4768            op,
4769            obj,
4770            inner,
4771            inserted: None,
4772        });
4773    }
4774    true
4775}
4776
4777/// Move `pos` back by one character, clamped to (0, 0).
4778pub(crate) fn retreat_one<H: crate::types::Host>(
4779    ed: &Editor<hjkl_buffer::Buffer, H>,
4780    pos: (usize, usize),
4781) -> (usize, usize) {
4782    let (r, c) = pos;
4783    if c > 0 {
4784        (r, c - 1)
4785    } else if r > 0 {
4786        let prev_len = buf_line_bytes(&ed.buffer, r - 1);
4787        (r - 1, prev_len)
4788    } else {
4789        (0, 0)
4790    }
4791}
4792
4793/// Variant of begin_insert that doesn't push_undo (caller already did).
4794fn begin_insert_noundo<H: crate::types::Host>(
4795    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4796    count: usize,
4797    reason: InsertReason,
4798) {
4799    let reason = if ed.vim.replaying {
4800        InsertReason::ReplayOnly
4801    } else {
4802        reason
4803    };
4804    let (row, col) = ed.cursor();
4805    ed.vim.insert_session = Some(InsertSession {
4806        count,
4807        row_min: row,
4808        row_max: row,
4809        before_rope: crate::types::Query::rope(&ed.buffer),
4810        reason,
4811        start_row: row,
4812        start_col: col,
4813    });
4814    ed.vim.mode = Mode::Insert;
4815    // Phase 6.3: keep current_mode in sync for callers that bypass step().
4816    ed.vim.current_mode = crate::VimMode::Insert;
4817    drop_blame_if_left_normal(ed);
4818}
4819
4820// ─── Operator × Motion application ─────────────────────────────────────────
4821
4822pub(crate) fn apply_op_with_motion<H: crate::types::Host>(
4823    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4824    op: Operator,
4825    motion: &Motion,
4826    count: usize,
4827) {
4828    let start = ed.cursor();
4829    // Tentatively apply motion to find the endpoint. Operator context
4830    // so `l` on the last char advances past-last (standard vim
4831    // exclusive-motion endpoint behaviour), enabling `dl` / `cl` /
4832    // `yl` to cover the final char.
4833    apply_motion_cursor_ctx(ed, motion, count, true);
4834    let end = ed.cursor();
4835    let kind = motion_kind(motion);
4836    // Restore cursor before selecting (so Yank leaves cursor at start).
4837    ed.jump_cursor(start.0, start.1);
4838
4839    // Comment is always linewise regardless of motion kind — toggle rows.
4840    if op == Operator::Comment {
4841        let top = start.0.min(end.0);
4842        let bot = start.0.max(end.0);
4843        ed.toggle_comment_range(top, bot);
4844        ed.vim.mode = Mode::Normal;
4845        return;
4846    }
4847
4848    run_operator_over_range(ed, op, start, end, kind);
4849}
4850
4851fn apply_op_with_text_object<H: crate::types::Host>(
4852    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4853    op: Operator,
4854    obj: TextObject,
4855    inner: bool,
4856    count: usize,
4857) {
4858    let Some((mut start, mut end, mut kind)) = text_object_range(ed, obj, inner, count) else {
4859        return;
4860    };
4861    // vim's exclusive-motion adjustment (`:h exclusive`), applied to the
4862    // OPERATOR form of an inner bracket object spanning multiple lines (the
4863    // visual form keeps the raw charwise region). When the exclusive end sits
4864    // in column 0, pull it back to the end of the previous line and make the
4865    // motion inclusive; if the start is at or before the first non-blank of its
4866    // line, promote to linewise. This is what makes `di{` on a contentful
4867    // multi-line block collapse to bare braces ("{\n}") and a clean block
4868    // delete its body linewise.
4869    if inner
4870        && matches!(obj, TextObject::Bracket(_))
4871        && kind == RangeKind::Exclusive
4872        && end.0 > start.0
4873        && end.1 == 0
4874    {
4875        let prev = end.0 - 1;
4876        let prev_len = buf_line_chars(&ed.buffer, prev);
4877        let fnb = buf_line(&ed.buffer, start.0)
4878            .unwrap_or_default()
4879            .chars()
4880            .take_while(|c| *c == ' ' || *c == '\t')
4881            .count();
4882        if start.1 <= fnb {
4883            start = (start.0, 0);
4884            end = (prev, prev_len);
4885            kind = RangeKind::Linewise;
4886        } else {
4887            end = (prev, prev_len.saturating_sub(1));
4888            kind = RangeKind::Inclusive;
4889        }
4890    }
4891    ed.jump_cursor(start.0, start.1);
4892    run_operator_over_range(ed, op, start, end, kind);
4893}
4894
4895fn motion_kind(motion: &Motion) -> RangeKind {
4896    match motion {
4897        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown => RangeKind::Linewise,
4898        Motion::FileTop | Motion::FileBottom => RangeKind::Linewise,
4899        Motion::ViewportTop | Motion::ViewportMiddle | Motion::ViewportBottom => {
4900            RangeKind::Linewise
4901        }
4902        Motion::WordEnd | Motion::BigWordEnd | Motion::WordEndBack | Motion::BigWordEndBack => {
4903            RangeKind::Inclusive
4904        }
4905        Motion::Find { .. } => RangeKind::Inclusive,
4906        Motion::MatchBracket => RangeKind::Inclusive,
4907        // `[(` / `])` etc. are exclusive: `d])` deletes up to but not including
4908        // the bracket; `d[(` deletes back to but not past the open bracket.
4909        Motion::UnmatchedBracket { .. } => RangeKind::Exclusive,
4910        // `$` now lands on the last char — operator ranges include it.
4911        Motion::LineEnd => RangeKind::Inclusive,
4912        // Linewise motions: +/-/_ land on the first non-blank of a line.
4913        Motion::FirstNonBlankNextLine
4914        | Motion::FirstNonBlankPrevLine
4915        | Motion::FirstNonBlankLine => RangeKind::Linewise,
4916        // [[/]]/[][/][ are charwise exclusive (land on the brace, brace excluded from operator).
4917        Motion::SectionBackward
4918        | Motion::SectionForward
4919        | Motion::SectionEndBackward
4920        | Motion::SectionEndForward => RangeKind::Exclusive,
4921        _ => RangeKind::Exclusive,
4922    }
4923}
4924
4925/// Linewise change of rows `[top_row, end_row]` (vim `cc`/`cj`/`Vc`/`cip`…).
4926///
4927/// Deletes the spanned lines, leaves one line carrying the first row's
4928/// leading whitespace (when `autoindent` is on), parks the cursor after
4929/// the indent, and enters insert mode. Records the full linewise payload
4930/// to the yank + delete registers and sets `change_mark_start` for the
4931/// `[`/`]` deferral. Calls `push_undo` internally — callers must NOT also
4932/// call it.
4933fn change_linewise_rows<H: crate::types::Host>(
4934    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4935    top_row: usize,
4936    end_row: usize,
4937) {
4938    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
4939    // Vim `:h '[`: stash change start for `]` deferral on insert-exit.
4940    ed.vim.change_mark_start = Some((top_row, 0));
4941    ed.push_undo();
4942    ed.sync_buffer_content_from_textarea();
4943    // Read the cut payload first so yank reflects every original line.
4944    let payload = read_vim_range(ed, (top_row, 0), (end_row, 0), RangeKind::Linewise);
4945    // Drop every row after the first (rows [top_row+1, end_row]).
4946    if end_row > top_row {
4947        ed.mutate_edit(Edit::DeleteRange {
4948            start: Position::new(top_row + 1, 0),
4949            end: Position::new(end_row, 0),
4950            kind: BufKind::Line,
4951        });
4952    }
4953    // Preserve the first row's leading whitespace when autoindent is on;
4954    // wipe the whole line content otherwise (cursor lands at col 0).
4955    let indent_chars = if ed.settings.autoindent {
4956        let line = hjkl_buffer::rope_line_str(&crate::types::Query::rope(&ed.buffer), top_row);
4957        line.chars().take_while(|c| *c == ' ' || *c == '\t').count()
4958    } else {
4959        0
4960    };
4961    let line_chars = buf_line_chars(&ed.buffer, top_row);
4962    if line_chars > indent_chars {
4963        ed.mutate_edit(Edit::DeleteRange {
4964            start: Position::new(top_row, indent_chars),
4965            end: Position::new(top_row, line_chars),
4966            kind: BufKind::Char,
4967        });
4968    }
4969    if !payload.is_empty() {
4970        ed.record_yank_to_host(payload.clone());
4971        ed.record_delete(payload, true);
4972    }
4973    buf_set_cursor_rc(&mut ed.buffer, top_row, indent_chars);
4974    ed.push_buffer_cursor_to_textarea();
4975    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
4976}
4977
4978fn run_operator_over_range<H: crate::types::Host>(
4979    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4980    op: Operator,
4981    start: (usize, usize),
4982    end: (usize, usize),
4983    kind: RangeKind,
4984) {
4985    let (top, bot) = order(start, end);
4986    // Charwise empty range (same position). For Delete/Yank there is nothing to
4987    // act on. For Change, vim still enters insert at that point — `ci(` on `()`
4988    // and `ci{` on a whitespace-only block both place the cursor inside and
4989    // start inserting without deleting anything.
4990    if top == bot && !matches!(kind, RangeKind::Linewise) {
4991        if op == Operator::Change {
4992            ed.vim.change_mark_start = Some(top);
4993            ed.push_undo();
4994            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
4995        }
4996        return;
4997    }
4998
4999    match op {
5000        Operator::Yank => {
5001            let text = read_vim_range(ed, top, bot, kind);
5002            if !text.is_empty() {
5003                ed.record_yank_to_host(text.clone());
5004                ed.record_yank(text, matches!(kind, RangeKind::Linewise));
5005            }
5006            // Vim `:h '[` / `:h ']`: after a yank `[` = first yanked char,
5007            // `]` = last yanked char. Mode-aware: linewise snaps to line
5008            // edges; charwise uses the actual inclusive endpoint.
5009            let rbr = match kind {
5010                RangeKind::Linewise => {
5011                    let last_col = buf_line_chars(&ed.buffer, bot.0).saturating_sub(1);
5012                    (bot.0, last_col)
5013                }
5014                RangeKind::Inclusive => (bot.0, bot.1),
5015                RangeKind::Exclusive => (bot.0, bot.1.saturating_sub(1)),
5016            };
5017            ed.set_mark('[', top);
5018            ed.set_mark(']', rbr);
5019            buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
5020            ed.push_buffer_cursor_to_textarea();
5021        }
5022        Operator::Delete => {
5023            ed.push_undo();
5024            cut_vim_range(ed, top, bot, kind);
5025            // After a charwise / inclusive delete the buffer cursor is
5026            // placed at `start` by the edit path. In Normal mode the
5027            // cursor max col is `line_len - 1`; clamp it here so e.g.
5028            // `d$` doesn't leave the cursor one past the new line end.
5029            if !matches!(kind, RangeKind::Linewise) {
5030                clamp_cursor_to_normal_mode(ed);
5031            }
5032            ed.vim.mode = Mode::Normal;
5033            // Vim `:h '[` / `:h ']`: after a delete both marks park at
5034            // the cursor position where the deletion collapsed (the join
5035            // point). Set after the cut and clamp so the position is final.
5036            let pos = ed.cursor();
5037            ed.set_mark('[', pos);
5038            ed.set_mark(']', pos);
5039        }
5040        Operator::Change => {
5041            // Vim `:h '[`: `[` is set to the start of the changed range
5042            // before the cut. `]` is deferred to insert-exit (AfterChange
5043            // path in finish_insert_session) where the cursor sits on the
5044            // last inserted char.
5045            if matches!(kind, RangeKind::Linewise) {
5046                // Linewise change (`cj`/`ck`/`cip`/`cap`/…): preserve the
5047                // first line's indent and leave exactly one row open for
5048                // insert. The helper handles push_undo + insert entry.
5049                change_linewise_rows(ed, top.0, bot.0);
5050            } else {
5051                // Charwise change: cut the range and enter insert.
5052                ed.vim.change_mark_start = Some(top);
5053                ed.push_undo();
5054                cut_vim_range(ed, top, bot, kind);
5055                begin_insert_noundo(ed, 1, InsertReason::AfterChange);
5056            }
5057        }
5058        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {
5059            apply_case_op_to_selection(ed, op, top, bot, kind);
5060        }
5061        Operator::Indent | Operator::Outdent => {
5062            // Indent / outdent are always linewise even when triggered
5063            // by a char-wise motion (e.g. `>w` indents the whole line).
5064            ed.push_undo();
5065            if op == Operator::Indent {
5066                indent_rows(ed, top.0, bot.0, 1);
5067            } else {
5068                outdent_rows(ed, top.0, bot.0, 1);
5069            }
5070            ed.vim.mode = Mode::Normal;
5071        }
5072        Operator::Fold => {
5073            // Always linewise — fold the spanned rows regardless of the
5074            // motion's natural kind. Cursor lands on `top.0` to mirror
5075            // the visual `zf` path.
5076            if bot.0 >= top.0 {
5077                ed.apply_fold_op(crate::types::FoldOp::Add {
5078                    start_row: top.0,
5079                    end_row: bot.0,
5080                    closed: true,
5081                });
5082            }
5083            buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
5084            ed.push_buffer_cursor_to_textarea();
5085            ed.vim.mode = Mode::Normal;
5086        }
5087        Operator::Reflow => {
5088            ed.push_undo();
5089            reflow_rows(ed, top.0, bot.0);
5090            ed.vim.mode = Mode::Normal;
5091        }
5092        Operator::ReflowKeepCursor => {
5093            // `gw{motion}` — reflow like `gq` but restore the cursor to the
5094            // character it was on before the reflow (vim's gw behaviour).
5095            let saved = ed.cursor();
5096            ed.push_undo();
5097            let (before, after) = reflow_rows_keep_cursor(ed, top.0, bot.0);
5098            let (new_row, new_col) = reflow_keep_cursor(top.0, saved.0, saved.1, &before, &after);
5099            buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
5100            ed.push_buffer_cursor_to_textarea();
5101            ed.sticky_col = Some(new_col);
5102            ed.vim.mode = Mode::Normal;
5103        }
5104        Operator::AutoIndent => {
5105            // Always linewise — like Indent/Outdent.
5106            ed.push_undo();
5107            auto_indent_rows(ed, top.0, bot.0);
5108            ed.vim.mode = Mode::Normal;
5109        }
5110        Operator::Filter => {
5111            // Filter is not dispatched through run_operator_over_range.
5112            // The app calls Editor::filter_range directly with a command string.
5113            // Reaching this arm means a caller invoked run_operator_over_range
5114            // with Operator::Filter by mistake — silently no-op.
5115        }
5116        Operator::Comment => {
5117            // Comment is dispatched through Editor::toggle_comment_range.
5118            // Reaching this arm is a caller mistake — silently no-op.
5119        }
5120    }
5121}
5122
5123// ─── Phase 4a pub range-mutation bridges ───────────────────────────────────
5124//
5125// These are `pub(crate)` entry points called by the five new pub methods on
5126// `Editor` (`delete_range`, `yank_range`, `change_range`, `indent_range`,
5127// `case_range`). They set `pending_register` from the caller-supplied char
5128// before delegating to the existing internal helpers so register semantics
5129// (unnamed `"`, named `"a`–`"z`, delete ring) are honoured exactly as in the
5130// FSM path.
5131//
5132// Do NOT call `run_operator_over_range` for Indent/Outdent or the three case
5133// operators — those share the FSM path but have dedicated parameter shapes
5134// (signed count, Operator-as-CaseOp) that map more cleanly to their own
5135// helpers.
5136
5137/// Delete the range `[start, end)` (interpretation determined by `kind`) and
5138/// stash the deleted text in `register`. `'"'` is the unnamed register.
5139pub(crate) fn delete_range_bridge<H: crate::types::Host>(
5140    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5141    start: (usize, usize),
5142    end: (usize, usize),
5143    kind: RangeKind,
5144    register: char,
5145) {
5146    ed.vim.pending_register = Some(register);
5147    run_operator_over_range(ed, Operator::Delete, start, end, kind);
5148}
5149
5150/// Yank (copy) the range `[start, end)` into `register` without mutating the
5151/// buffer. `'"'` is the unnamed register.
5152pub(crate) fn yank_range_bridge<H: crate::types::Host>(
5153    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5154    start: (usize, usize),
5155    end: (usize, usize),
5156    kind: RangeKind,
5157    register: char,
5158) {
5159    ed.vim.pending_register = Some(register);
5160    run_operator_over_range(ed, Operator::Yank, start, end, kind);
5161}
5162
5163/// Delete the range `[start, end)` and enter Insert mode (vim `c` operator).
5164/// The deleted text is stashed in `register`. Mode transitions to Insert on
5165/// return; the caller must not issue further normal-mode ops until the insert
5166/// session ends.
5167pub(crate) fn change_range_bridge<H: crate::types::Host>(
5168    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5169    start: (usize, usize),
5170    end: (usize, usize),
5171    kind: RangeKind,
5172    register: char,
5173) {
5174    ed.vim.pending_register = Some(register);
5175    run_operator_over_range(ed, Operator::Change, start, end, kind);
5176}
5177
5178/// Indent (`count > 0`) or outdent (`count < 0`) the row span `[start.0,
5179/// end.0]`. `shiftwidth` overrides the editor's `settings().shiftwidth` for
5180/// this call; pass `0` to use the editor setting. The column parts of `start`
5181/// / `end` are ignored — indent is always linewise.
5182pub(crate) fn indent_range_bridge<H: crate::types::Host>(
5183    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5184    start: (usize, usize),
5185    end: (usize, usize),
5186    count: i32,
5187    shiftwidth: u32,
5188) {
5189    if count == 0 {
5190        return;
5191    }
5192    let (top_row, bot_row) = if start.0 <= end.0 {
5193        (start.0, end.0)
5194    } else {
5195        (end.0, start.0)
5196    };
5197    // Temporarily override shiftwidth when the caller provides one.
5198    let original_sw = ed.settings().shiftwidth;
5199    if shiftwidth > 0 {
5200        ed.settings_mut().shiftwidth = shiftwidth as usize;
5201    }
5202    ed.push_undo();
5203    let abs_count = count.unsigned_abs() as usize;
5204    if count > 0 {
5205        indent_rows(ed, top_row, bot_row, abs_count);
5206    } else {
5207        outdent_rows(ed, top_row, bot_row, abs_count);
5208    }
5209    if shiftwidth > 0 {
5210        ed.settings_mut().shiftwidth = original_sw;
5211    }
5212    ed.vim.mode = Mode::Normal;
5213}
5214
5215/// Apply a case transformation (`Uppercase` / `Lowercase` / `ToggleCase`) to
5216/// the range `[start, end)`. Only the three case `Operator` variants are valid;
5217/// other variants are silently ignored (no-op).
5218pub(crate) fn case_range_bridge<H: crate::types::Host>(
5219    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5220    start: (usize, usize),
5221    end: (usize, usize),
5222    kind: RangeKind,
5223    op: Operator,
5224) {
5225    match op {
5226        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {}
5227        _ => return,
5228    }
5229    let (top, bot) = order(start, end);
5230    apply_case_op_to_selection(ed, op, top, bot, kind);
5231}
5232
5233// ─── Phase 4e pub block-shape range-mutation bridges ───────────────────────
5234//
5235// These are `pub(crate)` entry points called by the four new pub methods on
5236// `Editor` (`delete_block`, `yank_block`, `change_block`, `indent_block`).
5237// They set `pending_register` from the caller-supplied char then delegate to
5238// `apply_block_operator` (after temporarily installing the 4-corner block as
5239// the engine's virtual VisualBlock selection). The editor's VisualBlock state
5240// fields (`block_anchor`, `block_vcol`) are overwritten, the op fires, then
5241// the fields are restored to their pre-call values. This ensures the engine's
5242// register / undo / mode semantics are exercised without requiring the caller
5243// to already be in VisualBlock mode.
5244//
5245// `indent_block` is a separate helper — it does not use `apply_block_operator`
5246// because indent/outdent are always linewise for blocks (vim behaviour).
5247
5248/// Delete a rectangular VisualBlock selection. `top_row`/`bot_row` are
5249/// inclusive line bounds; `left_col`/`right_col` are inclusive char-column
5250/// bounds. Short lines that don't reach `right_col` lose only the chars
5251/// that exist (ragged-edge, matching engine FSM). `register` is honoured;
5252/// `'"'` selects the unnamed register.
5253pub(crate) fn delete_block_bridge<H: crate::types::Host>(
5254    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5255    top_row: usize,
5256    bot_row: usize,
5257    left_col: usize,
5258    right_col: usize,
5259    register: char,
5260) {
5261    ed.vim.pending_register = Some(register);
5262    let saved_anchor = ed.vim.block_anchor;
5263    let saved_vcol = ed.vim.block_vcol;
5264    ed.vim.block_anchor = (top_row, left_col);
5265    ed.vim.block_vcol = right_col;
5266    // Compute clamped col before the mutable borrow for buf_set_cursor_rc.
5267    let clamped = right_col.min(buf_line_chars(&ed.buffer, bot_row).saturating_sub(1));
5268    // Place cursor at bot_row / right_col so block_bounds resolves correctly.
5269    buf_set_cursor_rc(&mut ed.buffer, bot_row, clamped);
5270    apply_block_operator(ed, Operator::Delete, 1);
5271    // Restore — block_anchor/vcol are only meaningful in VisualBlock mode;
5272    // after the op we're in Normal so restoring is a no-op for the user but
5273    // keeps state coherent if the caller inspects fields.
5274    ed.vim.block_anchor = saved_anchor;
5275    ed.vim.block_vcol = saved_vcol;
5276}
5277
5278/// Yank a rectangular VisualBlock selection into `register`.
5279pub(crate) fn yank_block_bridge<H: crate::types::Host>(
5280    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5281    top_row: usize,
5282    bot_row: usize,
5283    left_col: usize,
5284    right_col: usize,
5285    register: char,
5286) {
5287    ed.vim.pending_register = Some(register);
5288    let saved_anchor = ed.vim.block_anchor;
5289    let saved_vcol = ed.vim.block_vcol;
5290    ed.vim.block_anchor = (top_row, left_col);
5291    ed.vim.block_vcol = right_col;
5292    let clamped = right_col.min(buf_line_chars(&ed.buffer, bot_row).saturating_sub(1));
5293    buf_set_cursor_rc(&mut ed.buffer, bot_row, clamped);
5294    apply_block_operator(ed, Operator::Yank, 1);
5295    ed.vim.block_anchor = saved_anchor;
5296    ed.vim.block_vcol = saved_vcol;
5297}
5298
5299/// Delete a rectangular VisualBlock selection and enter Insert mode (`c`).
5300/// The deleted text is stashed in `register`. Mode is Insert on return.
5301pub(crate) fn change_block_bridge<H: crate::types::Host>(
5302    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5303    top_row: usize,
5304    bot_row: usize,
5305    left_col: usize,
5306    right_col: usize,
5307    register: char,
5308) {
5309    ed.vim.pending_register = Some(register);
5310    let saved_anchor = ed.vim.block_anchor;
5311    let saved_vcol = ed.vim.block_vcol;
5312    ed.vim.block_anchor = (top_row, left_col);
5313    ed.vim.block_vcol = right_col;
5314    let clamped = right_col.min(buf_line_chars(&ed.buffer, bot_row).saturating_sub(1));
5315    buf_set_cursor_rc(&mut ed.buffer, bot_row, clamped);
5316    apply_block_operator(ed, Operator::Change, 1);
5317    ed.vim.block_anchor = saved_anchor;
5318    ed.vim.block_vcol = saved_vcol;
5319}
5320
5321/// Indent (`count > 0`) or outdent (`count < 0`) rows `top_row..=bot_row`.
5322/// Column bounds are ignored — vim's block indent is always linewise.
5323/// `count == 0` is a no-op.
5324pub(crate) fn indent_block_bridge<H: crate::types::Host>(
5325    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5326    top_row: usize,
5327    bot_row: usize,
5328    count: i32,
5329) {
5330    if count == 0 {
5331        return;
5332    }
5333    ed.push_undo();
5334    let abs = count.unsigned_abs() as usize;
5335    if count > 0 {
5336        indent_rows(ed, top_row, bot_row, abs);
5337    } else {
5338        outdent_rows(ed, top_row, bot_row, abs);
5339    }
5340    ed.vim.mode = Mode::Normal;
5341}
5342
5343/// Auto-indent (v1 dumb shiftwidth) the row span `[start.0, end.0]`. Column
5344/// parts are ignored — auto-indent is always linewise. See
5345/// `auto_indent_rows` for the algorithm and its v1 limitations.
5346pub(crate) fn auto_indent_range_bridge<H: crate::types::Host>(
5347    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5348    start: (usize, usize),
5349    end: (usize, usize),
5350) {
5351    let (top_row, bot_row) = if start.0 <= end.0 {
5352        (start.0, end.0)
5353    } else {
5354        (end.0, start.0)
5355    };
5356    ed.push_undo();
5357    auto_indent_rows(ed, top_row, bot_row);
5358    ed.vim.mode = Mode::Normal;
5359}
5360
5361// ─── Phase 4b pub text-object resolution bridges ───────────────────────────
5362//
5363// These are `pub(crate)` entry points called by the four new pub methods on
5364// `Editor` (`text_object_inner_word`, `text_object_around_word`,
5365// `text_object_inner_big_word`, `text_object_around_big_word`). They delegate
5366// to `word_text_object` — the existing private resolver — without touching any
5367// operator, register, or mode state. Pure functions: only `&Editor` required.
5368
5369/// Resolve the range of `iw` (inner word) at the current cursor position.
5370/// Returns `None` if no word exists at the cursor.
5371pub(crate) fn text_object_inner_word_bridge<H: crate::types::Host>(
5372    ed: &Editor<hjkl_buffer::Buffer, H>,
5373) -> Option<((usize, usize), (usize, usize))> {
5374    word_text_object(ed, true, false)
5375}
5376
5377/// Resolve the range of `aw` (around word) at the current cursor position.
5378/// Includes trailing whitespace (or leading whitespace if no trailing exists).
5379pub(crate) fn text_object_around_word_bridge<H: crate::types::Host>(
5380    ed: &Editor<hjkl_buffer::Buffer, H>,
5381) -> Option<((usize, usize), (usize, usize))> {
5382    word_text_object(ed, false, false)
5383}
5384
5385/// Resolve the range of `iW` (inner WORD) at the current cursor position.
5386/// A WORD is any run of non-whitespace characters (no punctuation splitting).
5387pub(crate) fn text_object_inner_big_word_bridge<H: crate::types::Host>(
5388    ed: &Editor<hjkl_buffer::Buffer, H>,
5389) -> Option<((usize, usize), (usize, usize))> {
5390    word_text_object(ed, true, true)
5391}
5392
5393/// Resolve the range of `aW` (around WORD) at the current cursor position.
5394/// Includes trailing whitespace (or leading whitespace if no trailing exists).
5395pub(crate) fn text_object_around_big_word_bridge<H: crate::types::Host>(
5396    ed: &Editor<hjkl_buffer::Buffer, H>,
5397) -> Option<((usize, usize), (usize, usize))> {
5398    word_text_object(ed, false, true)
5399}
5400
5401// ─── Phase 4c pub text-object resolution bridges (quote + bracket) ──────────
5402//
5403// `pub(crate)` entry points called by the four new pub methods on `Editor`
5404// (`text_object_inner_quote`, `text_object_around_quote`,
5405// `text_object_inner_bracket`, `text_object_around_bracket`). They delegate to
5406// `quote_text_object` / `bracket_text_object` — the existing private resolvers
5407// — without touching any operator, register, or mode state.
5408//
5409// `bracket_text_object` returns `Option<(Pos, Pos, RangeKind)>`; the bridges
5410// strip the `RangeKind` tag so callers see a uniform
5411// `Option<((usize,usize),(usize,usize))>` shape, consistent with 4b.
5412
5413/// Resolve the range of `i<quote>` (inner quote) at the current cursor
5414/// position. `quote` is one of `'"'`, `'\''`, or `` '`' ``. Returns `None`
5415/// when the cursor's line contains fewer than two occurrences of `quote`.
5416pub(crate) fn text_object_inner_quote_bridge<H: crate::types::Host>(
5417    ed: &Editor<hjkl_buffer::Buffer, H>,
5418    quote: char,
5419) -> Option<((usize, usize), (usize, usize))> {
5420    quote_text_object(ed, quote, true)
5421}
5422
5423/// Resolve the range of `a<quote>` (around quote) at the current cursor
5424/// position. Includes surrounding whitespace on one side per vim semantics.
5425pub(crate) fn text_object_around_quote_bridge<H: crate::types::Host>(
5426    ed: &Editor<hjkl_buffer::Buffer, H>,
5427    quote: char,
5428) -> Option<((usize, usize), (usize, usize))> {
5429    quote_text_object(ed, quote, false)
5430}
5431
5432/// Resolve the range of `i<bracket>` (inner bracket pair). `open` must be
5433/// one of `'('`, `'{'`, `'['`, `'<'`; the corresponding close is derived
5434/// internally. Returns `None` when no enclosing pair is found. The returned
5435/// range excludes the bracket characters themselves. Multi-line bracket pairs
5436/// whose content spans more than one line are reported as a charwise range
5437/// covering the first content character through the last content character
5438/// (RangeKind metadata is stripped — callers receive start/end only).
5439pub(crate) fn text_object_inner_bracket_bridge<H: crate::types::Host>(
5440    ed: &Editor<hjkl_buffer::Buffer, H>,
5441    open: char,
5442) -> Option<((usize, usize), (usize, usize))> {
5443    bracket_text_object(ed, open, true, 1).map(|(s, e, _kind)| (s, e))
5444}
5445
5446/// Resolve the range of `a<bracket>` (around bracket pair). Includes the
5447/// bracket characters themselves. `open` must be one of `'('`, `'{'`, `'['`,
5448/// `'<'`.
5449pub(crate) fn text_object_around_bracket_bridge<H: crate::types::Host>(
5450    ed: &Editor<hjkl_buffer::Buffer, H>,
5451    open: char,
5452) -> Option<((usize, usize), (usize, usize))> {
5453    bracket_text_object(ed, open, false, 1).map(|(s, e, _kind)| (s, e))
5454}
5455
5456// ── Sentence bridges (is / as) ─────────────────────────────────────────────
5457
5458/// Resolve the range of `is` (inner sentence) at the cursor. Excludes
5459/// trailing whitespace.
5460pub(crate) fn text_object_inner_sentence_bridge<H: crate::types::Host>(
5461    ed: &Editor<hjkl_buffer::Buffer, H>,
5462) -> Option<((usize, usize), (usize, usize))> {
5463    sentence_text_object(ed, true)
5464}
5465
5466/// Resolve the range of `as` (around sentence) at the cursor. Includes
5467/// trailing whitespace.
5468pub(crate) fn text_object_around_sentence_bridge<H: crate::types::Host>(
5469    ed: &Editor<hjkl_buffer::Buffer, H>,
5470) -> Option<((usize, usize), (usize, usize))> {
5471    sentence_text_object(ed, false)
5472}
5473
5474// ── Paragraph bridges (ip / ap) ────────────────────────────────────────────
5475
5476/// Resolve the range of `ip` (inner paragraph) at the cursor. A paragraph
5477/// is a block of non-blank lines bounded by blank lines or buffer edges.
5478pub(crate) fn text_object_inner_paragraph_bridge<H: crate::types::Host>(
5479    ed: &Editor<hjkl_buffer::Buffer, H>,
5480) -> Option<((usize, usize), (usize, usize))> {
5481    paragraph_text_object(ed, true)
5482}
5483
5484/// Resolve the range of `ap` (around paragraph) at the cursor. Includes one
5485/// trailing blank line when present.
5486pub(crate) fn text_object_around_paragraph_bridge<H: crate::types::Host>(
5487    ed: &Editor<hjkl_buffer::Buffer, H>,
5488) -> Option<((usize, usize), (usize, usize))> {
5489    paragraph_text_object(ed, false)
5490}
5491
5492// ── Tag bridges (it / at) ──────────────────────────────────────────────────
5493
5494/// Resolve the range of `it` (inner tag) at the cursor. Matches XML/HTML-style
5495/// `<tag>...</tag>` pairs; returns the range of inner content between the open
5496/// and close tags.
5497pub(crate) fn text_object_inner_tag_bridge<H: crate::types::Host>(
5498    ed: &Editor<hjkl_buffer::Buffer, H>,
5499) -> Option<((usize, usize), (usize, usize))> {
5500    tag_text_object(ed, true)
5501}
5502
5503/// Resolve the range of `at` (around tag) at the cursor. Includes the open
5504/// and close tag delimiters themselves.
5505pub(crate) fn text_object_around_tag_bridge<H: crate::types::Host>(
5506    ed: &Editor<hjkl_buffer::Buffer, H>,
5507) -> Option<((usize, usize), (usize, usize))> {
5508    tag_text_object(ed, false)
5509}
5510
5511// ─── Rope utility helpers ──────────────────────────────────────────────────
5512
5513/// Return row `r` from a rope as an owned `String`, stripping the
5514/// trailing `\n` that ropey includes on non-final lines.
5515pub(crate) fn rope_line_to_str(rope: &ropey::Rope, r: usize) -> String {
5516    let s = rope.line(r).to_string();
5517    // ropey includes the newline; strip it so callers see bare content.
5518    if s.ends_with('\n') {
5519        s[..s.len() - 1].to_string()
5520    } else {
5521        s
5522    }
5523}
5524
5525/// Join rows `lo..=hi` from a rope into a single `String` separated by
5526/// `\n`. Callers must ensure `lo <= hi < rope.len_lines()`.
5527pub(crate) fn rope_row_range_str(rope: &ropey::Rope, lo: usize, hi: usize) -> String {
5528    let n = rope.len_lines();
5529    let lo = lo.min(n.saturating_sub(1));
5530    let hi = hi.min(n.saturating_sub(1));
5531    if lo > hi {
5532        return String::new();
5533    }
5534    // Use byte-slice to grab the full range in one rope walk.
5535    let start_byte = rope.line_to_byte(lo);
5536    // End byte: start of line hi+1, minus the newline separator, or
5537    // len_bytes() when hi is the last line.
5538    let end_byte = if hi + 1 < n {
5539        // line_to_byte(hi+1) points at the \n-terminated start of
5540        // the next line; step back one byte to drop that trailing \n.
5541        rope.line_to_byte(hi + 1).saturating_sub(1)
5542    } else {
5543        rope.len_bytes()
5544    };
5545    rope.byte_slice(start_byte..end_byte).to_string()
5546}
5547
5548/// Snapshot all rows from a rope as `Vec<String>` (no trailing `\n`).
5549/// Use only when the caller truly needs mutable per-row access; prefer
5550/// rope iterators otherwise.
5551pub(crate) fn rope_to_lines_vec(rope: &ropey::Rope) -> Vec<String> {
5552    let n = rope.len_lines();
5553    (0..n).map(|r| rope_line_to_str(rope, r)).collect()
5554}
5555
5556/// Pure greedy word-wrap of a slice of lines to `width` chars.
5557/// Returns `(original_slice, wrapped_lines)`.
5558/// Blank lines are preserved as paragraph separators.
5559fn greedy_wrap(original: &[String], width: usize) -> Vec<String> {
5560    let mut wrapped: Vec<String> = Vec::new();
5561    let mut paragraph: Vec<String> = Vec::new();
5562    let flush = |para: &mut Vec<String>, out: &mut Vec<String>, width: usize| {
5563        if para.is_empty() {
5564            return;
5565        }
5566        let words = para.join(" ");
5567        let mut current = String::new();
5568        for word in words.split_whitespace() {
5569            let extra = if current.is_empty() {
5570                word.chars().count()
5571            } else {
5572                current.chars().count() + 1 + word.chars().count()
5573            };
5574            if extra > width && !current.is_empty() {
5575                out.push(std::mem::take(&mut current));
5576                current.push_str(word);
5577            } else if current.is_empty() {
5578                current.push_str(word);
5579            } else {
5580                current.push(' ');
5581                current.push_str(word);
5582            }
5583        }
5584        if !current.is_empty() {
5585            out.push(current);
5586        }
5587        para.clear();
5588    };
5589    for line in original {
5590        if line.trim().is_empty() {
5591            flush(&mut paragraph, &mut wrapped, width);
5592            wrapped.push(String::new());
5593        } else {
5594            paragraph.push(line.clone());
5595        }
5596    }
5597    flush(&mut paragraph, &mut wrapped, width);
5598    wrapped
5599}
5600
5601/// Greedy word-wrap the rows in `[top, bot]` to `settings.textwidth`.
5602/// Splits on blank-line boundaries so paragraph structure is
5603/// preserved. Each paragraph's words are joined with single spaces
5604/// before re-wrapping. Cursor lands at `(top, 0)` after the call
5605/// (via `ed.restore`).
5606fn reflow_rows<H: crate::types::Host>(
5607    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5608    top: usize,
5609    bot: usize,
5610) {
5611    let width = ed.settings().textwidth.max(1);
5612    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
5613    let bot = bot.min(lines.len().saturating_sub(1));
5614    if top > bot {
5615        return;
5616    }
5617    let original = lines[top..=bot].to_vec();
5618    let wrapped = greedy_wrap(&original, width);
5619
5620    // vim leaves the cursor on the last NON-BLANK line of the reflowed range
5621    // (a trailing blank from `ap` etc. is not counted).
5622    let last_offset = wrapped
5623        .iter()
5624        .rposition(|l| !l.trim().is_empty())
5625        .unwrap_or(0);
5626    let last_row = top + last_offset;
5627
5628    // Splice back. push_undo above means `u` reverses.
5629    let after: Vec<String> = lines.split_off(bot + 1);
5630    lines.truncate(top);
5631    lines.extend(wrapped);
5632    lines.extend(after);
5633    ed.restore(lines, (last_row, 0));
5634    move_first_non_whitespace(ed);
5635    ed.mark_content_dirty();
5636}
5637
5638/// Same reflow as `reflow_rows` but also returns the pre-reflow slice
5639/// and the wrapped lines so the caller can compute a character-preserving
5640/// cursor position via [`reflow_keep_cursor`].
5641fn reflow_rows_keep_cursor<H: crate::types::Host>(
5642    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5643    top: usize,
5644    bot: usize,
5645) -> (Vec<String>, Vec<String>) {
5646    let width = ed.settings().textwidth.max(1);
5647    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
5648    let bot = bot.min(lines.len().saturating_sub(1));
5649    if top > bot {
5650        return (Vec::new(), Vec::new());
5651    }
5652    let original = lines[top..=bot].to_vec();
5653    let wrapped = greedy_wrap(&original, width);
5654
5655    let after: Vec<String> = lines.split_off(bot + 1);
5656    lines.truncate(top);
5657    lines.extend(wrapped.clone());
5658    lines.extend(after);
5659    ed.restore(lines, (top, 0));
5660    ed.mark_content_dirty();
5661    (original, wrapped)
5662}
5663
5664/// Compute the new `(row, col)` that preserves the character the cursor
5665/// was on after `reflow_rows` has been applied to `[top, bot]`.
5666///
5667/// Algorithm (mirrors nvim's `gw` behaviour):
5668/// 1. Count the char-index of `(cursor_row, cursor_col)` relative to the
5669///    start of line `top` in `before_lines` (the pre-reflow snapshot).
5670/// 2. Walk the `after_lines` (the wrapped output) to find the row/col
5671///    that has the same char index.
5672///
5673/// If the cursor was past the end of the reflowed content (e.g. beyond
5674/// the last char), we clamp to the last char of the last reflowed line.
5675fn reflow_keep_cursor(
5676    top: usize,
5677    cursor_row: usize,
5678    cursor_col: usize,
5679    before_lines: &[String],
5680    after_lines: &[String],
5681) -> (usize, usize) {
5682    // Char offset of cursor within the before_lines range.
5683    // Each line contributes its chars; lines are separated by a single
5684    // space in the collapsed paragraph — but since reflow joins everything
5685    // and re-wraps with spaces, counting by chars-per-line (plus the
5686    // conceptual space separator between lines) mirrors the join.
5687    //
5688    // The simpler approach (which nvim appears to use): the cursor offset
5689    // within the range is the sum of chars in lines before cursor_row
5690    // (each + 1 for the space/newline separator) plus cursor_col, then
5691    // find that position in the wrapped text.
5692    //
5693    // Actually, since reflow collapses whitespace (split_whitespace),
5694    // the simplest approach is to track the cursor's char in the ORIGINAL
5695    // concatenated text and find it in the reflowed text.
5696
5697    // Build the original range text as it appears when joined for wrapping:
5698    // same as what reflow does internally — join with spaces.
5699    // But we want raw character index, so we accumulate char counts per line
5700    // (without the trailing newline).
5701    let relative_row = cursor_row.saturating_sub(top);
5702    let mut char_offset: usize = 0;
5703    for (i, line) in before_lines.iter().enumerate() {
5704        if i == relative_row {
5705            // Add clamped col within this line.
5706            let line_len = line.chars().count();
5707            char_offset += cursor_col.min(line_len);
5708            break;
5709        }
5710        // Each line contributes its chars plus a newline (or space boundary).
5711        char_offset += line.chars().count() + 1;
5712    }
5713
5714    // Now find char_offset in after_lines.
5715    let mut remaining = char_offset;
5716    for (i, line) in after_lines.iter().enumerate() {
5717        let len = line.chars().count();
5718        if remaining <= len {
5719            // The col is clamped to line_len - 1 in Normal mode.
5720            let col = remaining.min(if len == 0 { 0 } else { len.saturating_sub(1) });
5721            return (top + i, col);
5722        }
5723        // Not on this line; subtract line len + 1 (newline separator).
5724        remaining = remaining.saturating_sub(len + 1);
5725    }
5726
5727    // Cursor was beyond the end of the reflowed content — clamp to last line.
5728    let last = after_lines.len().saturating_sub(1);
5729    let last_len = after_lines
5730        .get(last)
5731        .map(|l| l.chars().count())
5732        .unwrap_or(0);
5733    let col = if last_len == 0 { 0 } else { last_len - 1 };
5734    (top + last, col)
5735}
5736
5737/// Transform the range `[top, bot]` (vim `RangeKind`) in place with
5738/// the given case operator. Cursor lands on `top` afterward — vim
5739/// convention for `gU{motion}` / `gu{motion}` / `g~{motion}`.
5740/// Preserves the textarea yank buffer (vim's case operators don't
5741/// touch registers).
5742fn apply_case_op_to_selection<H: crate::types::Host>(
5743    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5744    op: Operator,
5745    top: (usize, usize),
5746    bot: (usize, usize),
5747    kind: RangeKind,
5748) {
5749    use hjkl_buffer::Edit;
5750    ed.push_undo();
5751    let saved_yank = ed.yank().to_string();
5752    let saved_yank_linewise = ed.vim.yank_linewise;
5753    let selection = cut_vim_range(ed, top, bot, kind);
5754    let transformed = match op {
5755        Operator::Uppercase => selection.to_uppercase(),
5756        Operator::Lowercase => selection.to_lowercase(),
5757        Operator::ToggleCase => toggle_case_str(&selection),
5758        Operator::Rot13 => rot13_str(&selection),
5759        _ => unreachable!(),
5760    };
5761    if !transformed.is_empty() {
5762        let cursor = buf_cursor_pos(&ed.buffer);
5763        ed.mutate_edit(Edit::InsertStr {
5764            at: cursor,
5765            text: transformed,
5766        });
5767    }
5768    buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
5769    ed.push_buffer_cursor_to_textarea();
5770    ed.set_yank(saved_yank);
5771    ed.vim.yank_linewise = saved_yank_linewise;
5772    ed.vim.mode = Mode::Normal;
5773}
5774
5775/// Prepend `count * shiftwidth` spaces to each row in `[top, bot]`.
5776/// Rows that are empty are skipped (vim leaves blank lines alone when
5777/// indenting). `shiftwidth` is read from `editor.settings()` so
5778/// `:set shiftwidth=N` takes effect on the next operation.
5779fn indent_rows<H: crate::types::Host>(
5780    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5781    top: usize,
5782    bot: usize,
5783    count: usize,
5784) {
5785    ed.sync_buffer_content_from_textarea();
5786    let width = ed.settings().shiftwidth * count.max(1);
5787    // Honour `expandtab` (#263): `>>` under `noexpandtab` must insert hard tabs,
5788    // not spaces. Render `width` columns as tabs (`width / tabstop`) plus any
5789    // sub-tab remainder as spaces; under `expandtab` it stays all spaces.
5790    let pad: String = if ed.settings().expandtab {
5791        " ".repeat(width)
5792    } else {
5793        let tabstop = ed.settings().tabstop.max(1);
5794        let tabs = width / tabstop;
5795        let spaces = width % tabstop;
5796        format!("{}{}", "\t".repeat(tabs), " ".repeat(spaces))
5797    };
5798    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
5799    let bot = bot.min(lines.len().saturating_sub(1));
5800    for line in lines.iter_mut().take(bot + 1).skip(top) {
5801        if !line.is_empty() {
5802            line.insert_str(0, &pad);
5803        }
5804    }
5805    // Restore cursor to first non-blank of the top row so the next
5806    // vertical motion aims sensibly — matches vim's `>>` convention.
5807    ed.restore(lines, (top, 0));
5808    move_first_non_whitespace(ed);
5809}
5810
5811/// Remove up to `count * shiftwidth` leading spaces (or tabs) from
5812/// each row in `[top, bot]`. Rows with less leading whitespace have
5813/// all their indent stripped, not clipped to zero length.
5814fn outdent_rows<H: crate::types::Host>(
5815    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5816    top: usize,
5817    bot: usize,
5818    count: usize,
5819) {
5820    ed.sync_buffer_content_from_textarea();
5821    let width = ed.settings().shiftwidth * count.max(1);
5822    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
5823    let bot = bot.min(lines.len().saturating_sub(1));
5824    for line in lines.iter_mut().take(bot + 1).skip(top) {
5825        let strip: usize = line
5826            .chars()
5827            .take(width)
5828            .take_while(|c| *c == ' ' || *c == '\t')
5829            .count();
5830        if strip > 0 {
5831            let byte_len: usize = line.chars().take(strip).map(|c| c.len_utf8()).sum();
5832            line.drain(..byte_len);
5833        }
5834    }
5835    ed.restore(lines, (top, 0));
5836    move_first_non_whitespace(ed);
5837}
5838
5839/// Count the number of open/close bracket pairs on a single line for the
5840/// auto-indent depth scanner. Only bare bracket scanning — does NOT handle
5841/// string literals or comments (v1 limitation, documented on
5842/// `auto_indent_range_bridge`).
5843/// Net bracket count `(open - close)` for a single line, skipping
5844/// brackets inside `//` line comments, `"..."` string literals, and
5845/// `'X'` char literals.
5846///
5847/// String / char escapes (`\"`, `\'`, `\\`) are honored so the closing
5848/// quote isn't missed when the literal contains a backslash.
5849///
5850/// Limitations:
5851/// - Block comments `/* ... */` are NOT tracked across lines (a single
5852///   line `/* foo { bar } */` is correctly skipped only because the
5853///   `/*` and `*/` are on the same line and we'd see `{` after `/*`).
5854///   For v1 we leave this since block comments mid-code are rare.
5855/// - Raw string literals `r"..."` / `r#"..."#` are NOT special-cased.
5856/// - Lifetime annotations like `'a` look like an unterminated char
5857///   literal — handled by the heuristic that a char literal MUST close
5858///   within the line; if the closing `'` isn't found, treat the `'` as
5859///   a normal character (lifetime).
5860///
5861/// Pre-fix the scan was naive — `//! ... }` on a doc comment
5862/// decremented depth, cascading wrong indentation through the rest of
5863/// the file. This caused ~19% of lines to mis-indent on a real Rust
5864/// source diagnostic.
5865fn bracket_net(line: &str) -> i32 {
5866    let mut net: i32 = 0;
5867    let mut chars = line.chars().peekable();
5868    while let Some(ch) = chars.next() {
5869        match ch {
5870            // `//` → rest of line is a comment, stop.
5871            '/' if chars.peek() == Some(&'/') => return net,
5872            '"' => {
5873                // String literal — consume until unescaped closing `"`.
5874                while let Some(c) = chars.next() {
5875                    match c {
5876                        '\\' => {
5877                            chars.next();
5878                        } // skip escape byte
5879                        '"' => break,
5880                        _ => {}
5881                    }
5882                }
5883            }
5884            '\'' => {
5885                // Char literal OR lifetime. A char literal closes within
5886                // a few chars (one or two for escapes). A lifetime is
5887                // `'ident` with no closing quote.
5888                //
5889                // Strategy: peek ahead for a closing `'`. If found
5890                // within ~4 chars, consume as char literal. Otherwise
5891                // treat the `'` as the start of a lifetime — leave the
5892                // remaining chars to be scanned normally.
5893                let saved: Vec<char> = chars.clone().take(5).collect();
5894                let close_idx = if saved.first() == Some(&'\\') {
5895                    saved.iter().skip(2).position(|&c| c == '\'').map(|p| p + 2)
5896                } else {
5897                    saved.iter().skip(1).position(|&c| c == '\'').map(|p| p + 1)
5898                };
5899                if let Some(idx) = close_idx {
5900                    for _ in 0..=idx {
5901                        chars.next();
5902                    }
5903                }
5904                // If no close found, leave chars alone — lifetime path.
5905            }
5906            '{' | '(' | '[' => net += 1,
5907            '}' | ')' | ']' => net -= 1,
5908            _ => {}
5909        }
5910    }
5911    net
5912}
5913
5914/// Reindent rows `[top, bot]` using shiftwidth-based bracket-depth counting.
5915///
5916/// The indent for each line is computed as follows:
5917/// 1. Scan all rows from 0 up to the target row, accumulating a bracket depth
5918///    (`depth`) from net open − close brackets per line. The scan starts at row
5919///    0 to give correct depth for code that appears mid-buffer.
5920/// 2. For the target line, peek at its first non-whitespace character:
5921///    if it is a close bracket (`}`, `)`, `]`) then `effective_depth =
5922///    depth.saturating_sub(1)`; otherwise `effective_depth = depth`.
5923/// 3. Strip the line's existing leading whitespace and prepend
5924///    `effective_depth × indent_unit` where `indent_unit` is `"\t"` when
5925///    `expandtab == false` or `" " × shiftwidth` when `expandtab == true`.
5926/// 4. Empty / whitespace-only lines are left empty (no trailing whitespace).
5927/// 5. After computing the new line, advance `depth` by the line's bracket
5928///    net count (open − close), where the leading close-bracket already
5929///    contributed `−1` to the net of its own line.
5930///
5931/// **v1 limitation**: the bracket scan is naive — it does not skip brackets
5932/// inside string literals (`"{"`, `'['`) or comments (`// {`). Code with
5933/// such patterns will produce incorrect indent depths. Tree-sitter / LSP
5934/// indentation is deferred to a follow-up.
5935fn auto_indent_rows<H: crate::types::Host>(
5936    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5937    top: usize,
5938    bot: usize,
5939) {
5940    ed.sync_buffer_content_from_textarea();
5941    let shiftwidth = ed.settings().shiftwidth;
5942    let expandtab = ed.settings().expandtab;
5943    let indent_unit: String = if expandtab {
5944        " ".repeat(shiftwidth)
5945    } else {
5946        "\t".to_string()
5947    };
5948
5949    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
5950    let bot = bot.min(lines.len().saturating_sub(1));
5951
5952    // Accumulate bracket depth from row 0 up to `top - 1` so we start with
5953    // the correct depth for the first line of the target range.
5954    let mut depth: i32 = 0;
5955    for line in lines.iter().take(top) {
5956        depth += bracket_net(line);
5957        if depth < 0 {
5958            depth = 0;
5959        }
5960    }
5961
5962    for line in lines.iter_mut().take(bot + 1).skip(top) {
5963        let trimmed_owned = line.trim_start().to_owned();
5964        // Empty / whitespace-only lines stay empty.
5965        if trimmed_owned.is_empty() {
5966            *line = String::new();
5967            // depth contribution from an empty line is zero; no bracket scan needed.
5968            continue;
5969        }
5970
5971        // Detect leading close-bracket for effective depth.
5972        let starts_with_close = trimmed_owned
5973            .chars()
5974            .next()
5975            .is_some_and(|c| matches!(c, '}' | ')' | ']'));
5976        // Chain continuation: a line starting with `.` (e.g. `.foo()`)
5977        // hangs off the previous expression and gets one extra indent
5978        // level, matching cargo fmt / clang-format conventions for
5979        // method chains like:
5980        //   let x = foo()
5981        //       .bar()
5982        //       .baz();
5983        // Range expressions (`..`) and try-chains (`?.`) are out of
5984        // scope for v1 — single leading `.` is the common case.
5985        let starts_with_dot = trimmed_owned.starts_with('.')
5986            && !trimmed_owned.starts_with("..")
5987            && !trimmed_owned.starts_with(".;");
5988        let effective_depth = if starts_with_close {
5989            depth.saturating_sub(1)
5990        } else if starts_with_dot {
5991            depth.saturating_add(1)
5992        } else {
5993            depth
5994        } as usize;
5995
5996        // Build new line: indent × depth + stripped content.
5997        let new_line = format!("{}{}", indent_unit.repeat(effective_depth), trimmed_owned);
5998
5999        // Advance depth by this line's net bracket count (scan trimmed content).
6000        depth += bracket_net(&trimmed_owned);
6001        if depth < 0 {
6002            depth = 0;
6003        }
6004
6005        *line = new_line;
6006    }
6007
6008    // Restore cursor to the first non-blank of `top` (vim parity for `==`).
6009    ed.restore(lines, (top, 0));
6010    move_first_non_whitespace(ed);
6011    // Record the touched row range so the host can display a visual flash.
6012    ed.last_indent_range = Some((top, bot));
6013}
6014
6015fn toggle_case_str(s: &str) -> String {
6016    s.chars()
6017        .map(|c| {
6018            if c.is_lowercase() {
6019                c.to_uppercase().next().unwrap_or(c)
6020            } else if c.is_uppercase() {
6021                c.to_lowercase().next().unwrap_or(c)
6022            } else {
6023                c
6024            }
6025        })
6026        .collect()
6027}
6028
6029fn order(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) {
6030    if a <= b { (a, b) } else { (b, a) }
6031}
6032
6033/// Clamp the buffer cursor to normal-mode valid position: col may not
6034/// exceed `line.chars().count().saturating_sub(1)` (or 0 on an empty
6035/// line). Vim applies this clamp on every return to Normal mode after an
6036/// operator or Esc-from-insert.
6037fn clamp_cursor_to_normal_mode<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
6038    let (row, col) = ed.cursor();
6039    let line_chars = buf_line_chars(&ed.buffer, row);
6040    let max_col = line_chars.saturating_sub(1);
6041    if col > max_col {
6042        buf_set_cursor_rc(&mut ed.buffer, row, max_col);
6043        ed.push_buffer_cursor_to_textarea();
6044    }
6045}
6046
6047// ─── dd/cc/yy ──────────────────────────────────────────────────────────────
6048
6049/// Expand a linewise `[start, end]` row range so it fully covers every CLOSED
6050/// fold it overlaps — vim's rule that a linewise operator on a closed fold acts
6051/// on the whole fold. Loops until stable so nested closed folds are absorbed.
6052fn expand_linewise_over_closed_folds(
6053    buf: &hjkl_buffer::Buffer,
6054    mut start: usize,
6055    mut end: usize,
6056) -> (usize, usize) {
6057    let folds = buf.folds();
6058    if folds.is_empty() {
6059        return (start, end);
6060    }
6061    loop {
6062        let mut changed = false;
6063        for f in &folds {
6064            if !f.closed {
6065                continue;
6066            }
6067            // Does this closed fold overlap the current range?
6068            if f.start_row <= end && f.end_row >= start {
6069                if f.start_row < start {
6070                    start = f.start_row;
6071                    changed = true;
6072                }
6073                if f.end_row > end {
6074                    end = f.end_row;
6075                    changed = true;
6076                }
6077            }
6078        }
6079        if !changed {
6080            break;
6081        }
6082    }
6083    (start, end)
6084}
6085
6086fn execute_line_op<H: crate::types::Host>(
6087    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6088    op: Operator,
6089    count: usize,
6090) {
6091    let (row, col) = ed.cursor();
6092    let total = buf_row_count(&ed.buffer);
6093    // Vim: `[count]op` for a linewise operator implies a `count_` motion that
6094    // moves `count - 1` lines down. On the last line that motion can't move at
6095    // all, so the whole operator aborts (E16) — `2dd`/`2yy`/`5>>`/`5<<` on the
6096    // final line are no-ops, not "operate on the one remaining line". When the
6097    // cursor is above the last line the motion clamps to the buffer end instead.
6098    //
6099    // A trailing newline is stored as a phantom empty final row, so the last
6100    // *content* line is one above it; use that as the boundary.
6101    let last_content_row = if total >= 2
6102        && buf_line(&ed.buffer, total - 1)
6103            .map(|s| s.is_empty())
6104            .unwrap_or(false)
6105    {
6106        total - 2
6107    } else {
6108        total.saturating_sub(1)
6109    };
6110    if count >= 2 && row >= last_content_row {
6111        return;
6112    }
6113    let end_row = (row + count.saturating_sub(1)).min(total.saturating_sub(1));
6114
6115    // Vim: a linewise operator (`dd`/`yy`/`cc`/`>>`/…) with the cursor on a
6116    // CLOSED fold operates on the ENTIRE fold, not just the cursor line. Expand
6117    // the `[row, end_row]` range to cover any closed fold it touches (repeats
6118    // until stable so nested folds are absorbed too).
6119    let (row, end_row) = expand_linewise_over_closed_folds(&ed.buffer, row, end_row);
6120
6121    match op {
6122        Operator::Yank => {
6123            // yy must not move the cursor.
6124            let text = read_vim_range(ed, (row, col), (end_row, 0), RangeKind::Linewise);
6125            if !text.is_empty() {
6126                ed.record_yank_to_host(text.clone());
6127                ed.record_yank(text, true);
6128            }
6129            // Vim `:h '[` / `:h ']`: yy/Nyy — linewise yank; `[` =
6130            // (top_row, 0), `]` = (bot_row, last_col).
6131            let last_col = buf_line_chars(&ed.buffer, end_row).saturating_sub(1);
6132            ed.set_mark('[', (row, 0));
6133            ed.set_mark(']', (end_row, last_col));
6134            buf_set_cursor_rc(&mut ed.buffer, row, col);
6135            ed.push_buffer_cursor_to_textarea();
6136            ed.vim.mode = Mode::Normal;
6137        }
6138        Operator::Delete => {
6139            ed.push_undo();
6140            let deleted_through_last = end_row + 1 >= total;
6141            cut_vim_range(ed, (row, col), (end_row, 0), RangeKind::Linewise);
6142            // Vim's `dd` / `Ndd` leaves the cursor on the *first
6143            // non-blank* of the line that now occupies `row` — or, if
6144            // the deletion consumed the last line, the line above it.
6145            let total_after = buf_row_count(&ed.buffer);
6146            let raw_target = if deleted_through_last {
6147                row.saturating_sub(1).min(total_after.saturating_sub(1))
6148            } else {
6149                row.min(total_after.saturating_sub(1))
6150            };
6151            // Clamp off the trailing phantom empty row that arises from a
6152            // buffer with a trailing newline (stored as ["...", ""]). If
6153            // the target row is the trailing empty row and there is a real
6154            // content row above it, use that instead — matching vim's view
6155            // that the trailing `\n` is a terminator, not a separator.
6156            let target_row = if raw_target > 0
6157                && raw_target + 1 == total_after
6158                && buf_line(&ed.buffer, raw_target)
6159                    .map(|s| s.is_empty())
6160                    .unwrap_or(false)
6161            {
6162                raw_target - 1
6163            } else {
6164                raw_target
6165            };
6166            buf_set_cursor_rc(&mut ed.buffer, target_row, 0);
6167            ed.push_buffer_cursor_to_textarea();
6168            move_first_non_whitespace(ed);
6169            ed.sticky_col = Some(ed.cursor().1);
6170            ed.vim.mode = Mode::Normal;
6171            // Vim `:h '[` / `:h ']`: dd/Ndd — both marks park at the
6172            // post-delete cursor position (the join point).
6173            let pos = ed.cursor();
6174            ed.set_mark('[', pos);
6175            ed.set_mark(']', pos);
6176        }
6177        Operator::Change => {
6178            // `cc` / `3cc`: delegate to the shared linewise-change helper
6179            // which preserves the first line's indent, leaves one row open,
6180            // and enters insert mode.
6181            change_linewise_rows(ed, row, end_row);
6182        }
6183        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {
6184            // `gUU` / `guu` / `g~~` / `g??` — linewise case/rot13 transform over
6185            // [row, end_row]. Preserve cursor on `row` (first non-blank
6186            // lines up with vim's behaviour).
6187            apply_case_op_to_selection(ed, op, (row, col), (end_row, 0), RangeKind::Linewise);
6188            // After case-op on a linewise range vim puts the cursor on
6189            // the first non-blank of the starting line.
6190            move_first_non_whitespace(ed);
6191        }
6192        Operator::Indent | Operator::Outdent => {
6193            // `>>` / `N>>` / `<<` / `N<<` — linewise indent / outdent.
6194            ed.push_undo();
6195            if op == Operator::Indent {
6196                indent_rows(ed, row, end_row, 1);
6197            } else {
6198                outdent_rows(ed, row, end_row, 1);
6199            }
6200            ed.sticky_col = Some(ed.cursor().1);
6201            ed.vim.mode = Mode::Normal;
6202        }
6203        // No doubled form — `zfzf` is two consecutive `zf` chords.
6204        Operator::Fold => unreachable!("Fold has no line-op double"),
6205        Operator::Reflow => {
6206            // `gqq` / `Ngqq` — reflow `count` rows starting at the cursor.
6207            ed.push_undo();
6208            reflow_rows(ed, row, end_row);
6209            move_first_non_whitespace(ed);
6210            ed.sticky_col = Some(ed.cursor().1);
6211            ed.vim.mode = Mode::Normal;
6212        }
6213        Operator::ReflowKeepCursor => {
6214            // `gww` / `Ngww` — reflow `count` rows starting at the cursor,
6215            // but leave the cursor at the character it was on before reflow.
6216            let saved = ed.cursor();
6217            ed.push_undo();
6218            let (before, after) = reflow_rows_keep_cursor(ed, row, end_row);
6219            let (new_row, new_col) = reflow_keep_cursor(row, saved.0, saved.1, &before, &after);
6220            buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
6221            ed.push_buffer_cursor_to_textarea();
6222            ed.sticky_col = Some(new_col);
6223            ed.vim.mode = Mode::Normal;
6224        }
6225        Operator::AutoIndent => {
6226            // `==` / `N==` — auto-indent `count` rows starting at cursor.
6227            ed.push_undo();
6228            auto_indent_rows(ed, row, end_row);
6229            ed.sticky_col = Some(ed.cursor().1);
6230            ed.vim.mode = Mode::Normal;
6231        }
6232        Operator::Filter => {
6233            // Filter is dispatched through Editor::filter_range, not here.
6234        }
6235        Operator::Comment => {
6236            // Comment is dispatched through Editor::toggle_comment_range, not here.
6237            // The doubled `gcc` path calls toggle_comment_range directly in
6238            // apply_after_g, then records last_change. execute_line_op should
6239            // not be reached for Comment — no-op if it is.
6240        }
6241    }
6242}
6243
6244// ─── Visual mode operators ─────────────────────────────────────────────────
6245
6246pub(crate) fn apply_visual_operator<H: crate::types::Host>(
6247    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6248    op: Operator,
6249    count: usize,
6250) {
6251    // `count` is the number of indent levels for `>` / `<` (vim `2>` = two
6252    // shiftwidths); other visual operators ignore it.
6253    let levels = count.max(1);
6254    match ed.vim.mode {
6255        Mode::VisualLine => {
6256            let cursor_row = buf_cursor_pos(&ed.buffer).row;
6257            let top = cursor_row.min(ed.vim.visual_line_anchor);
6258            let bot = cursor_row.max(ed.vim.visual_line_anchor);
6259            ed.vim.yank_linewise = true;
6260            match op {
6261                Operator::Yank => {
6262                    let text = read_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
6263                    if !text.is_empty() {
6264                        ed.record_yank_to_host(text.clone());
6265                        ed.record_yank(text, true);
6266                    }
6267                    buf_set_cursor_rc(&mut ed.buffer, top, 0);
6268                    ed.push_buffer_cursor_to_textarea();
6269                    ed.vim.mode = Mode::Normal;
6270                }
6271                Operator::Delete => {
6272                    ed.push_undo();
6273                    cut_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
6274                    ed.vim.mode = Mode::Normal;
6275                }
6276                Operator::Change => {
6277                    // Vim `Vc` / `Vjc`: same linewise-change semantics as
6278                    // `cc` — preserve first line's indent, enter insert.
6279                    change_linewise_rows(ed, top, bot);
6280                }
6281                Operator::Uppercase
6282                | Operator::Lowercase
6283                | Operator::ToggleCase
6284                | Operator::Rot13 => {
6285                    let bot = buf_cursor_pos(&ed.buffer)
6286                        .row
6287                        .max(ed.vim.visual_line_anchor);
6288                    apply_case_op_to_selection(ed, op, (top, 0), (bot, 0), RangeKind::Linewise);
6289                    move_first_non_whitespace(ed);
6290                }
6291                Operator::Indent | Operator::Outdent => {
6292                    ed.push_undo();
6293                    let (cursor_row, _) = ed.cursor();
6294                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
6295                    if op == Operator::Indent {
6296                        indent_rows(ed, top, bot, levels);
6297                    } else {
6298                        outdent_rows(ed, top, bot, levels);
6299                    }
6300                    ed.vim.mode = Mode::Normal;
6301                }
6302                Operator::Reflow => {
6303                    ed.push_undo();
6304                    let (cursor_row, _) = ed.cursor();
6305                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
6306                    reflow_rows(ed, top, bot);
6307                    ed.vim.mode = Mode::Normal;
6308                }
6309                Operator::ReflowKeepCursor => {
6310                    let saved = ed.cursor();
6311                    ed.push_undo();
6312                    let (cursor_row, _) = ed.cursor();
6313                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
6314                    let (before, after) = reflow_rows_keep_cursor(ed, top, bot);
6315                    let (new_row, new_col) =
6316                        reflow_keep_cursor(top, saved.0, saved.1, &before, &after);
6317                    buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
6318                    ed.push_buffer_cursor_to_textarea();
6319                    ed.vim.mode = Mode::Normal;
6320                }
6321                Operator::AutoIndent => {
6322                    ed.push_undo();
6323                    let (cursor_row, _) = ed.cursor();
6324                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
6325                    auto_indent_rows(ed, top, bot);
6326                    ed.vim.mode = Mode::Normal;
6327                }
6328                // Filter is dispatched through Editor::filter_range, not here.
6329                Operator::Filter => {}
6330                // Comment is dispatched through the app layer (engine_actions.rs), not here.
6331                Operator::Comment => {}
6332                // Visual `zf` is handled inline in `handle_after_z`,
6333                // never routed through this dispatcher.
6334                Operator::Fold => unreachable!("Visual zf takes its own path"),
6335            }
6336        }
6337        Mode::Visual => {
6338            ed.vim.yank_linewise = false;
6339            let anchor = ed.vim.visual_anchor;
6340            let cursor = ed.cursor();
6341            let (top, bot) = order(anchor, cursor);
6342            match op {
6343                Operator::Yank => {
6344                    let text = read_vim_range(ed, top, bot, RangeKind::Inclusive);
6345                    if !text.is_empty() {
6346                        ed.record_yank_to_host(text.clone());
6347                        ed.record_yank(text, false);
6348                    }
6349                    buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
6350                    ed.push_buffer_cursor_to_textarea();
6351                    ed.vim.mode = Mode::Normal;
6352                }
6353                Operator::Delete => {
6354                    ed.push_undo();
6355                    cut_vim_range(ed, top, bot, RangeKind::Inclusive);
6356                    ed.vim.mode = Mode::Normal;
6357                }
6358                Operator::Change => {
6359                    ed.push_undo();
6360                    cut_vim_range(ed, top, bot, RangeKind::Inclusive);
6361                    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
6362                }
6363                Operator::Uppercase
6364                | Operator::Lowercase
6365                | Operator::ToggleCase
6366                | Operator::Rot13 => {
6367                    // Anchor stays where the visual selection started.
6368                    let anchor = ed.vim.visual_anchor;
6369                    let cursor = ed.cursor();
6370                    let (top, bot) = order(anchor, cursor);
6371                    apply_case_op_to_selection(ed, op, top, bot, RangeKind::Inclusive);
6372                }
6373                Operator::Indent | Operator::Outdent => {
6374                    ed.push_undo();
6375                    let anchor = ed.vim.visual_anchor;
6376                    let cursor = ed.cursor();
6377                    let (top, bot) = order(anchor, cursor);
6378                    if op == Operator::Indent {
6379                        indent_rows(ed, top.0, bot.0, levels);
6380                    } else {
6381                        outdent_rows(ed, top.0, bot.0, levels);
6382                    }
6383                    ed.vim.mode = Mode::Normal;
6384                }
6385                Operator::Reflow => {
6386                    ed.push_undo();
6387                    let anchor = ed.vim.visual_anchor;
6388                    let cursor = ed.cursor();
6389                    let (top, bot) = order(anchor, cursor);
6390                    reflow_rows(ed, top.0, bot.0);
6391                    ed.vim.mode = Mode::Normal;
6392                }
6393                Operator::ReflowKeepCursor => {
6394                    let saved = ed.cursor();
6395                    ed.push_undo();
6396                    let anchor = ed.vim.visual_anchor;
6397                    let cursor = ed.cursor();
6398                    let (top, bot) = order(anchor, cursor);
6399                    let (before, after) = reflow_rows_keep_cursor(ed, top.0, bot.0);
6400                    let (new_row, new_col) =
6401                        reflow_keep_cursor(top.0, saved.0, saved.1, &before, &after);
6402                    buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
6403                    ed.push_buffer_cursor_to_textarea();
6404                    ed.vim.mode = Mode::Normal;
6405                }
6406                Operator::AutoIndent => {
6407                    ed.push_undo();
6408                    let anchor = ed.vim.visual_anchor;
6409                    let cursor = ed.cursor();
6410                    let (top, bot) = order(anchor, cursor);
6411                    auto_indent_rows(ed, top.0, bot.0);
6412                    ed.vim.mode = Mode::Normal;
6413                }
6414                // Filter is dispatched through Editor::filter_range, not here.
6415                Operator::Filter => {}
6416                // Comment is dispatched through the app layer (engine_actions.rs), not here.
6417                Operator::Comment => {}
6418                Operator::Fold => unreachable!("Visual zf takes its own path"),
6419            }
6420        }
6421        Mode::VisualBlock => apply_block_operator(ed, op, levels),
6422        _ => {}
6423    }
6424}
6425
6426/// Compute `(top_row, bot_row, left_col, right_col)` for the current
6427/// VisualBlock selection. Columns are inclusive on both ends. Uses the
6428/// tracked virtual column (updated by h/l, preserved across j/k) so
6429/// ragged / empty rows don't collapse the block's width.
6430fn block_bounds<H: crate::types::Host>(
6431    ed: &Editor<hjkl_buffer::Buffer, H>,
6432) -> (usize, usize, usize, usize) {
6433    let (ar, ac) = ed.vim.block_anchor;
6434    let (cr, _) = ed.cursor();
6435    let cc = ed.vim.block_vcol;
6436    let top = ar.min(cr);
6437    let bot = ar.max(cr);
6438    let left = ac.min(cc);
6439    let right = ac.max(cc);
6440    (top, bot, left, right)
6441}
6442
6443/// Update the virtual column after a motion in VisualBlock mode.
6444/// Horizontal motions sync `block_vcol` to the new cursor column;
6445/// vertical / non-h/l motions leave it alone so the intended column
6446/// survives clamping to shorter lines.
6447pub(crate) fn update_block_vcol<H: crate::types::Host>(
6448    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6449    motion: &Motion,
6450) {
6451    match motion {
6452        Motion::Left
6453        | Motion::Right
6454        | Motion::SpaceFwd
6455        | Motion::BackspaceBack
6456        | Motion::WordFwd
6457        | Motion::BigWordFwd
6458        | Motion::WordBack
6459        | Motion::BigWordBack
6460        | Motion::WordEnd
6461        | Motion::BigWordEnd
6462        | Motion::WordEndBack
6463        | Motion::BigWordEndBack
6464        | Motion::LineStart
6465        | Motion::FirstNonBlank
6466        | Motion::LineEnd
6467        | Motion::Find { .. }
6468        | Motion::FindRepeat { .. }
6469        | Motion::MatchBracket => {
6470            ed.vim.block_vcol = ed.cursor().1;
6471        }
6472        // Up / Down / FileTop / FileBottom / Search — preserve vcol.
6473        _ => {}
6474    }
6475}
6476
6477/// Yank / delete / change / replace a rectangular selection. Yanked text
6478/// is stored as one string per row joined with `\n` so pasting reproduces
6479/// the block as sequential lines. (Vim's true block-paste reinserts as
6480/// columns; we render the content with our char-wise paste path.)
6481fn apply_block_operator<H: crate::types::Host>(
6482    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6483    op: Operator,
6484    count: usize,
6485) {
6486    let (top, bot, left, right) = block_bounds(ed);
6487    // Snapshot the block text for yank / clipboard.
6488    let yank = block_yank(ed, top, bot, left, right);
6489
6490    match op {
6491        Operator::Yank => {
6492            if !yank.is_empty() {
6493                ed.record_yank_to_host(yank.clone());
6494                ed.record_yank(yank, false);
6495            }
6496            ed.vim.mode = Mode::Normal;
6497            ed.jump_cursor(top, left);
6498        }
6499        Operator::Delete => {
6500            ed.push_undo();
6501            delete_block_contents(ed, top, bot, left, right);
6502            if !yank.is_empty() {
6503                ed.record_yank_to_host(yank.clone());
6504                ed.record_delete(yank, false);
6505            }
6506            ed.vim.mode = Mode::Normal;
6507            ed.jump_cursor(top, left);
6508        }
6509        Operator::Change => {
6510            ed.push_undo();
6511            delete_block_contents(ed, top, bot, left, right);
6512            if !yank.is_empty() {
6513                ed.record_yank_to_host(yank.clone());
6514                ed.record_delete(yank, false);
6515            }
6516            ed.jump_cursor(top, left);
6517            begin_insert_noundo(
6518                ed,
6519                1,
6520                InsertReason::BlockChange {
6521                    top,
6522                    bot,
6523                    col: left,
6524                },
6525            );
6526        }
6527        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {
6528            ed.push_undo();
6529            transform_block_case(ed, op, top, bot, left, right);
6530            ed.vim.mode = Mode::Normal;
6531            ed.jump_cursor(top, left);
6532        }
6533        Operator::Indent | Operator::Outdent => {
6534            // VisualBlock `>` / `<` falls back to linewise indent over
6535            // the block's row range — vim does the same (column-wise
6536            // indent/outdent doesn't make sense).
6537            ed.push_undo();
6538            if op == Operator::Indent {
6539                indent_rows(ed, top, bot, count.max(1));
6540            } else {
6541                outdent_rows(ed, top, bot, count.max(1));
6542            }
6543            ed.vim.mode = Mode::Normal;
6544        }
6545        Operator::Fold => unreachable!("Visual zf takes its own path"),
6546        Operator::Reflow => {
6547            // Reflow over the block falls back to linewise reflow over
6548            // the row range — column slicing for `gq` doesn't make
6549            // sense.
6550            ed.push_undo();
6551            reflow_rows(ed, top, bot);
6552            ed.vim.mode = Mode::Normal;
6553        }
6554        Operator::ReflowKeepCursor => {
6555            // `gw` over a block: same fallback as `gq` but restore cursor.
6556            let saved = ed.cursor();
6557            ed.push_undo();
6558            let (before, after) = reflow_rows_keep_cursor(ed, top, bot);
6559            let (new_row, new_col) = reflow_keep_cursor(top, saved.0, saved.1, &before, &after);
6560            buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
6561            ed.push_buffer_cursor_to_textarea();
6562            ed.vim.mode = Mode::Normal;
6563        }
6564        Operator::AutoIndent => {
6565            // AutoIndent over the block falls back to linewise
6566            // auto-indent over the row range.
6567            ed.push_undo();
6568            auto_indent_rows(ed, top, bot);
6569            ed.vim.mode = Mode::Normal;
6570        }
6571        // Filter is dispatched through Editor::filter_range, not here.
6572        Operator::Filter => {}
6573        // Comment is dispatched through the app layer (engine_actions.rs), not here.
6574        Operator::Comment => {}
6575    }
6576}
6577
6578/// In-place case transform over the rectangular block
6579/// `(top..=bot, left..=right)`. Rows shorter than `left` are left
6580/// untouched — vim behaves the same way (ragged blocks).
6581fn transform_block_case<H: crate::types::Host>(
6582    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6583    op: Operator,
6584    top: usize,
6585    bot: usize,
6586    left: usize,
6587    right: usize,
6588) {
6589    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
6590    for r in top..=bot.min(lines.len().saturating_sub(1)) {
6591        let chars: Vec<char> = lines[r].chars().collect();
6592        if left >= chars.len() {
6593            continue;
6594        }
6595        let end = (right + 1).min(chars.len());
6596        let head: String = chars[..left].iter().collect();
6597        let mid: String = chars[left..end].iter().collect();
6598        let tail: String = chars[end..].iter().collect();
6599        let transformed = match op {
6600            Operator::Uppercase => mid.to_uppercase(),
6601            Operator::Lowercase => mid.to_lowercase(),
6602            Operator::ToggleCase => toggle_case_str(&mid),
6603            Operator::Rot13 => rot13_str(&mid),
6604            _ => mid,
6605        };
6606        lines[r] = format!("{head}{transformed}{tail}");
6607    }
6608    let saved_yank = ed.yank().to_string();
6609    let saved_linewise = ed.vim.yank_linewise;
6610    ed.restore(lines, (top, left));
6611    ed.set_yank(saved_yank);
6612    ed.vim.yank_linewise = saved_linewise;
6613}
6614
6615fn block_yank<H: crate::types::Host>(
6616    ed: &Editor<hjkl_buffer::Buffer, H>,
6617    top: usize,
6618    bot: usize,
6619    left: usize,
6620    right: usize,
6621) -> String {
6622    let rope = crate::types::Query::rope(&ed.buffer);
6623    let n = rope.len_lines();
6624    let mut rows: Vec<String> = Vec::new();
6625    for r in top..=bot {
6626        if r >= n {
6627            break;
6628        }
6629        let line = rope_line_to_str(&rope, r);
6630        let chars: Vec<char> = line.chars().collect();
6631        let end = (right + 1).min(chars.len());
6632        if left >= chars.len() {
6633            rows.push(String::new());
6634        } else {
6635            rows.push(chars[left..end].iter().collect());
6636        }
6637    }
6638    rows.join("\n")
6639}
6640
6641fn delete_block_contents<H: crate::types::Host>(
6642    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6643    top: usize,
6644    bot: usize,
6645    left: usize,
6646    right: usize,
6647) {
6648    use hjkl_buffer::{Edit, MotionKind, Position};
6649    ed.sync_buffer_content_from_textarea();
6650    let last_row = bot.min(buf_row_count(&ed.buffer).saturating_sub(1));
6651    if last_row < top {
6652        return;
6653    }
6654    ed.mutate_edit(Edit::DeleteRange {
6655        start: Position::new(top, left),
6656        end: Position::new(last_row, right),
6657        kind: MotionKind::Block,
6658    });
6659    ed.push_buffer_cursor_to_textarea();
6660}
6661
6662/// Replace each character cell in the block with `ch`.
6663pub(crate) fn block_replace<H: crate::types::Host>(
6664    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6665    ch: char,
6666) {
6667    let (top, bot, left, right) = block_bounds(ed);
6668    ed.push_undo();
6669    ed.sync_buffer_content_from_textarea();
6670    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
6671    for r in top..=bot.min(lines.len().saturating_sub(1)) {
6672        let chars: Vec<char> = lines[r].chars().collect();
6673        if left >= chars.len() {
6674            continue;
6675        }
6676        let end = (right + 1).min(chars.len());
6677        let before: String = chars[..left].iter().collect();
6678        let middle: String = std::iter::repeat_n(ch, end - left).collect();
6679        let after: String = chars[end..].iter().collect();
6680        lines[r] = format!("{before}{middle}{after}");
6681    }
6682    reset_textarea_lines(ed, lines);
6683    ed.vim.mode = Mode::Normal;
6684    ed.jump_cursor(top, left);
6685}
6686
6687/// Replace buffer content with `lines` while preserving the cursor.
6688/// Used by indent / outdent / block_replace to wholesale rewrite
6689/// rows without going through the per-edit funnel.
6690fn reset_textarea_lines<H: crate::types::Host>(
6691    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6692    lines: Vec<String>,
6693) {
6694    let cursor = ed.cursor();
6695    crate::types::BufferEdit::replace_all(&mut ed.buffer, &lines.join("\n"));
6696    buf_set_cursor_rc(&mut ed.buffer, cursor.0, cursor.1);
6697    ed.mark_content_dirty();
6698}
6699
6700// ─── Visual-line helpers ───────────────────────────────────────────────────
6701
6702// ─── Text-object range computation ─────────────────────────────────────────
6703
6704/// Cursor position as `(row, col)`.
6705type Pos = (usize, usize);
6706
6707/// Returns `(start, end, kind)` where `end` is *exclusive* (one past the
6708/// last character to act on). `kind` is `Linewise` for line-oriented text
6709/// objects like paragraphs and `Exclusive` otherwise.
6710pub(crate) fn text_object_range<H: crate::types::Host>(
6711    ed: &Editor<hjkl_buffer::Buffer, H>,
6712    obj: TextObject,
6713    inner: bool,
6714    count: usize,
6715) -> Option<(Pos, Pos, RangeKind)> {
6716    match obj {
6717        TextObject::Word { big } => {
6718            word_text_object(ed, inner, big).map(|(s, e)| (s, e, RangeKind::Exclusive))
6719        }
6720        TextObject::Quote(q) => {
6721            quote_text_object(ed, q, inner).map(|(s, e)| (s, e, RangeKind::Exclusive))
6722        }
6723        TextObject::Bracket(open) => bracket_text_object(ed, open, inner, count),
6724        TextObject::Paragraph => {
6725            paragraph_text_object(ed, inner).map(|(s, e)| (s, e, RangeKind::Linewise))
6726        }
6727        TextObject::XmlTag => tag_text_object(ed, inner).map(|(s, e)| (s, e, RangeKind::Exclusive)),
6728        TextObject::Sentence => {
6729            sentence_text_object(ed, inner).map(|(s, e)| (s, e, RangeKind::Exclusive))
6730        }
6731    }
6732}
6733
6734/// `(` / `)` — walk to the next sentence boundary in `forward` direction.
6735/// Returns `(row, col)` of the boundary's first non-whitespace cell, or
6736/// `None` when already at the buffer's edge in that direction.
6737fn sentence_boundary<H: crate::types::Host>(
6738    ed: &Editor<hjkl_buffer::Buffer, H>,
6739    forward: bool,
6740) -> Option<(usize, usize)> {
6741    let rope = crate::types::Query::rope(&ed.buffer);
6742    let n_lines = rope.len_lines();
6743    if n_lines == 0 {
6744        return None;
6745    }
6746    // Per-line char counts (excluding trailing \n) for pos↔idx conversion.
6747    let line_lens: Vec<usize> = (0..n_lines)
6748        .map(|r| rope_line_to_str(&rope, r).chars().count())
6749        .collect();
6750    let pos_to_idx = |pos: (usize, usize)| -> usize {
6751        let idx: usize = line_lens.iter().take(pos.0).map(|&len| len + 1).sum();
6752        idx + pos.1
6753    };
6754    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
6755        for (r, &len) in line_lens.iter().enumerate() {
6756            if idx <= len {
6757                return (r, idx);
6758            }
6759            idx -= len + 1;
6760        }
6761        let last = n_lines.saturating_sub(1);
6762        (last, line_lens[last])
6763    };
6764    // Build flat char vector: rope chars already include \n between lines.
6765    // ropey's last line has no trailing \n; intermediate ones do.
6766    let mut chars: Vec<char> = rope.chars().collect();
6767    // Strip a trailing \n if ropey emitted one on the final line.
6768    if chars.last() == Some(&'\n') {
6769        chars.pop();
6770    }
6771    if chars.is_empty() {
6772        return None;
6773    }
6774    let total = chars.len();
6775    let cursor_idx = pos_to_idx(ed.cursor()).min(total - 1);
6776    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
6777
6778    if forward {
6779        // Walk forward looking for a terminator run followed by
6780        // whitespace; land on the first non-whitespace cell after.
6781        let mut i = cursor_idx + 1;
6782        while i < total {
6783            if is_terminator(chars[i]) {
6784                while i + 1 < total && is_terminator(chars[i + 1]) {
6785                    i += 1;
6786                }
6787                if i + 1 >= total {
6788                    return None;
6789                }
6790                if chars[i + 1].is_whitespace() {
6791                    let mut j = i + 1;
6792                    while j < total && chars[j].is_whitespace() {
6793                        j += 1;
6794                    }
6795                    if j >= total {
6796                        return None;
6797                    }
6798                    return Some(idx_to_pos(j));
6799                }
6800            }
6801            i += 1;
6802        }
6803        None
6804    } else {
6805        // Walk backward to find the start of the current sentence (if
6806        // we're already at the start, jump to the previous sentence's
6807        // start instead).
6808        let find_start = |from: usize| -> Option<usize> {
6809            let mut start = from;
6810            while start > 0 {
6811                let prev = chars[start - 1];
6812                if prev.is_whitespace() {
6813                    let mut k = start - 1;
6814                    while k > 0 && chars[k - 1].is_whitespace() {
6815                        k -= 1;
6816                    }
6817                    if k > 0 && is_terminator(chars[k - 1]) {
6818                        break;
6819                    }
6820                }
6821                start -= 1;
6822            }
6823            while start < total && chars[start].is_whitespace() {
6824                start += 1;
6825            }
6826            (start < total).then_some(start)
6827        };
6828        let current_start = find_start(cursor_idx)?;
6829        if current_start < cursor_idx {
6830            return Some(idx_to_pos(current_start));
6831        }
6832        // Already at the sentence start — step over the boundary into
6833        // the previous sentence and find its start.
6834        let mut k = current_start;
6835        while k > 0 && chars[k - 1].is_whitespace() {
6836            k -= 1;
6837        }
6838        if k == 0 {
6839            return None;
6840        }
6841        let prev_start = find_start(k - 1)?;
6842        Some(idx_to_pos(prev_start))
6843    }
6844}
6845
6846/// `is` / `as` — sentence: text up to and including the next sentence
6847/// terminator (`.`, `?`, `!`). Vim treats `.`/`?`/`!` followed by
6848/// whitespace (or end-of-line) as a boundary; runs of consecutive
6849/// terminators stay attached to the same sentence. `as` extends to
6850/// include trailing whitespace; `is` does not.
6851fn sentence_text_object<H: crate::types::Host>(
6852    ed: &Editor<hjkl_buffer::Buffer, H>,
6853    inner: bool,
6854) -> Option<((usize, usize), (usize, usize))> {
6855    let rope = crate::types::Query::rope(&ed.buffer);
6856    let n_lines = rope.len_lines();
6857    if n_lines == 0 {
6858        return None;
6859    }
6860    // Flatten the buffer so a sentence can span lines (vim's behaviour).
6861    // Newlines count as whitespace for boundary detection.
6862    let line_lens: Vec<usize> = (0..n_lines)
6863        .map(|r| rope_line_to_str(&rope, r).chars().count())
6864        .collect();
6865    let pos_to_idx = |pos: (usize, usize)| -> usize {
6866        let idx: usize = line_lens.iter().take(pos.0).map(|&len| len + 1).sum();
6867        idx + pos.1
6868    };
6869    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
6870        for (r, &len) in line_lens.iter().enumerate() {
6871            if idx <= len {
6872                return (r, idx);
6873            }
6874            idx -= len + 1;
6875        }
6876        let last = n_lines.saturating_sub(1);
6877        (last, line_lens[last])
6878    };
6879    let mut chars: Vec<char> = rope.chars().collect();
6880    if chars.last() == Some(&'\n') {
6881        chars.pop();
6882    }
6883    if chars.is_empty() {
6884        return None;
6885    }
6886
6887    let cursor_idx = pos_to_idx(ed.cursor()).min(chars.len() - 1);
6888    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
6889
6890    // Walk backward from cursor to find the start of the current
6891    // sentence. A boundary is: whitespace immediately after a run of
6892    // terminators (or start-of-buffer).
6893    let mut start = cursor_idx;
6894    while start > 0 {
6895        let prev = chars[start - 1];
6896        if prev.is_whitespace() {
6897            // Check if the whitespace follows a terminator — if so,
6898            // we've crossed a sentence boundary; the sentence begins
6899            // at the first non-whitespace cell *after* this run.
6900            let mut k = start - 1;
6901            while k > 0 && chars[k - 1].is_whitespace() {
6902                k -= 1;
6903            }
6904            if k > 0 && is_terminator(chars[k - 1]) {
6905                break;
6906            }
6907        }
6908        start -= 1;
6909    }
6910    // Skip leading whitespace (vim doesn't include it in the
6911    // sentence body).
6912    while start < chars.len() && chars[start].is_whitespace() {
6913        start += 1;
6914    }
6915    if start >= chars.len() {
6916        return None;
6917    }
6918
6919    // Walk forward to the sentence end (last terminator before the
6920    // next whitespace boundary).
6921    let mut end = start;
6922    while end < chars.len() {
6923        if is_terminator(chars[end]) {
6924            // Consume any consecutive terminators (e.g. `?!`).
6925            while end + 1 < chars.len() && is_terminator(chars[end + 1]) {
6926                end += 1;
6927            }
6928            // If followed by whitespace or end-of-buffer, that's the
6929            // boundary.
6930            if end + 1 >= chars.len() || chars[end + 1].is_whitespace() {
6931                break;
6932            }
6933        }
6934        end += 1;
6935    }
6936    // Inclusive end → exclusive end_idx.
6937    let end_idx = (end + 1).min(chars.len());
6938
6939    let final_end = if inner {
6940        end_idx
6941    } else {
6942        // `as`: include trailing whitespace (but stop before the next
6943        // newline so we don't gobble a paragraph break — vim keeps
6944        // sentences within a paragraph for the trailing-ws extension).
6945        let mut e = end_idx;
6946        while e < chars.len() && chars[e].is_whitespace() && chars[e] != '\n' {
6947            e += 1;
6948        }
6949        e
6950    };
6951
6952    Some((idx_to_pos(start), idx_to_pos(final_end)))
6953}
6954
6955/// `it` / `at` — XML tag pair text object. Builds a flat char index of
6956/// the buffer, walks `<...>` tokens to pair tags via a stack, and
6957/// returns the innermost pair containing the cursor.
6958fn tag_text_object<H: crate::types::Host>(
6959    ed: &Editor<hjkl_buffer::Buffer, H>,
6960    inner: bool,
6961) -> Option<((usize, usize), (usize, usize))> {
6962    let rope = crate::types::Query::rope(&ed.buffer);
6963    let n_lines = rope.len_lines();
6964    if n_lines == 0 {
6965        return None;
6966    }
6967    // Flatten char positions so we can compare cursor against tag
6968    // ranges without per-row arithmetic. `\n` between lines counts as
6969    // a single char.
6970    let line_lens: Vec<usize> = (0..n_lines)
6971        .map(|r| rope_line_to_str(&rope, r).chars().count())
6972        .collect();
6973    let pos_to_idx = |pos: (usize, usize)| -> usize {
6974        let idx: usize = line_lens.iter().take(pos.0).map(|&len| len + 1).sum();
6975        idx + pos.1
6976    };
6977    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
6978        for (r, &len) in line_lens.iter().enumerate() {
6979            if idx <= len {
6980                return (r, idx);
6981            }
6982            idx -= len + 1;
6983        }
6984        let last = n_lines.saturating_sub(1);
6985        (last, line_lens[last])
6986    };
6987    let mut chars: Vec<char> = rope.chars().collect();
6988    if chars.last() == Some(&'\n') {
6989        chars.pop();
6990    }
6991    let cursor_idx = pos_to_idx(ed.cursor());
6992
6993    // Walk `<...>` tokens. Track open tags on a stack; on a matching
6994    // close pop and consider the pair a candidate when the cursor lies
6995    // inside its content range. Innermost wins (replace whenever a
6996    // tighter range turns up). Also track the first complete pair that
6997    // starts at or after the cursor so we can fall back to a forward
6998    // scan (targets.vim-style) when the cursor isn't inside any tag.
6999    let mut stack: Vec<(usize, usize, String)> = Vec::new(); // (open_start, content_start, name)
7000    let mut innermost: Option<(usize, usize, usize, usize)> = None;
7001    let mut next_after: Option<(usize, usize, usize, usize)> = None;
7002    let mut i = 0;
7003    while i < chars.len() {
7004        if chars[i] != '<' {
7005            i += 1;
7006            continue;
7007        }
7008        let mut j = i + 1;
7009        while j < chars.len() && chars[j] != '>' {
7010            j += 1;
7011        }
7012        if j >= chars.len() {
7013            break;
7014        }
7015        let inside: String = chars[i + 1..j].iter().collect();
7016        let close_end = j + 1;
7017        let trimmed = inside.trim();
7018        if trimmed.starts_with('!') || trimmed.starts_with('?') {
7019            i = close_end;
7020            continue;
7021        }
7022        if let Some(rest) = trimmed.strip_prefix('/') {
7023            let name = rest.split_whitespace().next().unwrap_or("").to_string();
7024            if !name.is_empty()
7025                && let Some(stack_idx) = stack.iter().rposition(|(_, _, n)| *n == name)
7026            {
7027                let (open_start, content_start, _) = stack[stack_idx].clone();
7028                stack.truncate(stack_idx);
7029                let content_end = i;
7030                let candidate = (open_start, content_start, content_end, close_end);
7031                // A pair encloses the cursor when the cursor lies anywhere
7032                // within the whole pair span — including ON the open or close
7033                // tag itself (vim `it`/`at` operate on the tag under the
7034                // cursor, not just its content). Innermost (tightest span)
7035                // wins; closes are seen innermost-first so the first enclosing
7036                // candidate is already the tightest.
7037                if cursor_idx >= open_start && cursor_idx < close_end {
7038                    innermost = match innermost {
7039                        Some((os, _, _, ce)) if os <= open_start && close_end <= ce => {
7040                            Some(candidate)
7041                        }
7042                        None => Some(candidate),
7043                        existing => existing,
7044                    };
7045                } else if open_start >= cursor_idx && next_after.is_none() {
7046                    next_after = Some(candidate);
7047                }
7048            }
7049        } else if !trimmed.ends_with('/') {
7050            let name: String = trimmed
7051                .split(|c: char| c.is_whitespace() || c == '/')
7052                .next()
7053                .unwrap_or("")
7054                .to_string();
7055            if !name.is_empty() {
7056                stack.push((i, close_end, name));
7057            }
7058        }
7059        i = close_end;
7060    }
7061
7062    let (open_start, content_start, content_end, close_end) = innermost.or(next_after)?;
7063    if inner {
7064        Some((idx_to_pos(content_start), idx_to_pos(content_end)))
7065    } else {
7066        Some((idx_to_pos(open_start), idx_to_pos(close_end)))
7067    }
7068}
7069
7070fn is_wordchar(c: char) -> bool {
7071    c.is_alphanumeric() || c == '_'
7072}
7073
7074// `is_keyword_char` lives in hjkl-buffer (used by word motions);
7075// engine re-uses it via `hjkl_buffer::is_keyword_char` so there's
7076// one parser, one default, one bug surface.
7077pub(crate) use hjkl_buffer::is_keyword_char;
7078
7079pub(crate) fn abbrev_kind(lhs: &str, iskeyword: &str) -> AbbrevKind {
7080    let chars: Vec<char> = lhs.chars().collect();
7081    if chars.is_empty() {
7082        return AbbrevKind::NonKw;
7083    }
7084    let last = *chars.last().unwrap();
7085    let last_is_kw = is_keyword_char(last, iskeyword);
7086    if !last_is_kw {
7087        return AbbrevKind::NonKw;
7088    }
7089    // last is keyword — check if all chars are keyword
7090    let all_kw = chars.iter().all(|&c| is_keyword_char(c, iskeyword));
7091    if all_kw {
7092        AbbrevKind::Full
7093    } else {
7094        AbbrevKind::End
7095    }
7096}
7097
7098/// Try to match and expand an abbreviation given the text before the cursor.
7099///
7100/// # Parameters
7101/// - `abbrevs` — the active abbreviation table (insert-mode entries).
7102/// - `line_before` — the text on the current line *before* the cursor (char slice).
7103/// - `mincol` — first column index (0-based, char-indexed) that belongs to the
7104///   current insert session on the **same row as the cursor**.  Chars before
7105///   `mincol` were in the buffer before insert mode started and must NOT be
7106///   consumed as part of the lhs.  When the cursor is on a different row than
7107///   `start_row`, `mincol` is treated as 0 (the entire line was typed in this
7108///   session).
7109/// - `trigger` — what the user did (typed a non-kw char, pressed CR/Esc/C-]).
7110/// - `iskeyword` — the active iskeyword spec string.
7111///
7112/// Returns `Some((lhs_char_len, rhs))` on a match, where `lhs_char_len` is the
7113/// number of characters to delete before the cursor (the lhs), and `rhs` is the
7114/// text to insert in their place.  Returns `None` when no abbreviation matches.
7115pub(crate) fn try_abbrev_expand(
7116    abbrevs: &[Abbrev],
7117    line_before: &str,
7118    mincol: usize,
7119    trigger: AbbrevTrigger,
7120    iskeyword: &str,
7121) -> Option<(usize, String)> {
7122    let chars: Vec<char> = line_before.chars().collect();
7123    let cursor_col = chars.len(); // col of the cursor (0-based)
7124
7125    for abbrev in abbrevs {
7126        if !abbrev.insert {
7127            continue;
7128        }
7129        let lhs_chars: Vec<char> = abbrev.lhs.chars().collect();
7130        if lhs_chars.is_empty() {
7131            continue;
7132        }
7133        let lhs_len = lhs_chars.len();
7134
7135        // Determine the lhs type.
7136        let kind = abbrev_kind(&abbrev.lhs, iskeyword);
7137
7138        // Trigger rules by lhs type.
7139        match kind {
7140            AbbrevKind::Full | AbbrevKind::End => {
7141                // full-id / end-id: trigger char must be a NON-keyword char
7142                // (space, punctuation, CR, Esc, C-]).
7143                let trigger_char_is_kw = match trigger {
7144                    AbbrevTrigger::NonKeyword(c) => is_keyword_char(c, iskeyword),
7145                    AbbrevTrigger::CtrlBracket | AbbrevTrigger::Cr | AbbrevTrigger::Esc => false,
7146                };
7147                if trigger_char_is_kw {
7148                    // A keyword trigger char would extend the word — no expand.
7149                    continue;
7150                }
7151            }
7152            AbbrevKind::NonKw => {
7153                // non-id: only expand on CR, Esc, C-].  NOT on regular typed chars.
7154                match trigger {
7155                    AbbrevTrigger::Cr | AbbrevTrigger::Esc | AbbrevTrigger::CtrlBracket => {}
7156                    AbbrevTrigger::NonKeyword(_) => continue,
7157                }
7158            }
7159        }
7160
7161        // Check that the text before the cursor ends with the lhs.
7162        if cursor_col < lhs_len {
7163            continue;
7164        }
7165        let lhs_start_col = cursor_col - lhs_len;
7166
7167        // Enforce mincol: the lhs must not start before the insert-start column.
7168        if lhs_start_col < mincol {
7169            continue;
7170        }
7171
7172        // Compare chars.
7173        let text_slice: &[char] = &chars[lhs_start_col..cursor_col];
7174        if text_slice != lhs_chars.as_slice() {
7175            continue;
7176        }
7177
7178        // Check "front" rule: the char immediately before the lhs.
7179        if lhs_start_col > 0 {
7180            let ch_before = chars[lhs_start_col - 1];
7181            match kind {
7182                AbbrevKind::Full => {
7183                    // full-id: char before lhs must be a non-keyword char.
7184                    // Single-char full-id exception: if the char before is a
7185                    // non-keyword char that is NOT space/tab, it is NOT recognised
7186                    // (vim `:h abbreviations`: "A word in front of a full-id abbrev
7187                    // is a non-keyword char; but a single char abbrev is not
7188                    // recognised after a non-blank, non-keyword char").
7189                    // Actually vim's rule: full-id is not recognised if the char
7190                    // before is a NON-keyword char other than space/tab AND the lhs
7191                    // is a single keyword char. For multi-char full-id the rule is
7192                    // just "char before must be non-keyword".
7193                    if is_keyword_char(ch_before, iskeyword) {
7194                        continue; // char before is keyword → lhs is part of a longer word
7195                    }
7196                    if lhs_len == 1 && ch_before != ' ' && ch_before != '\t' {
7197                        // single-char full-id: non-blank non-keyword before → skip
7198                        continue;
7199                    }
7200                }
7201                AbbrevKind::End => {
7202                    // end-id: no constraint on the char before (any char is fine,
7203                    // including keyword chars — the non-keyword prefix of the lhs
7204                    // acts as the boundary).
7205                }
7206                AbbrevKind::NonKw => {
7207                    // non-id: the char before the lhs must be blank (space/tab) or
7208                    // it must be the start of the typed portion (mincol boundary).
7209                    if ch_before != ' ' && ch_before != '\t' {
7210                        continue;
7211                    }
7212                }
7213            }
7214        }
7215        // lhs_start_col == 0 means the lhs starts at the very beginning of the
7216        // line (or at the insert-start position); all types accept this.
7217
7218        return Some((lhs_len, abbrev.rhs.clone()));
7219    }
7220
7221    None
7222}
7223
7224/// Check abbreviations and apply the expansion if a match is found.
7225///
7226/// Reads the current cursor position and the text before it, calls
7227/// `try_abbrev_expand`, and if a match is found, deletes the `lhs` chars
7228/// and inserts the `rhs`. Returns `true` if an expansion was applied.
7229///
7230/// `trigger` is what the user did; the trigger char itself is NOT inserted
7231/// here — the caller inserts it (or not, in the case of `C-]`).
7232pub(crate) fn check_and_apply_abbrev<H: crate::types::Host>(
7233    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7234    trigger: AbbrevTrigger,
7235) -> bool {
7236    use hjkl_buffer::{Edit, Position};
7237
7238    // Collect the data we need without holding borrows.
7239    let cursor = buf_cursor_pos(&ed.buffer);
7240    let row = cursor.row;
7241    let col = cursor.col;
7242    let line_before: String = {
7243        let line = buf_line(&ed.buffer, row).unwrap_or_default();
7244        line.chars().take(col).collect()
7245    };
7246    let (mincol, on_start_row) = if let Some(ref s) = ed.vim.insert_session {
7247        if row == s.start_row {
7248            (s.start_col, true)
7249        } else {
7250            (0, false)
7251        }
7252    } else {
7253        (0, false)
7254    };
7255    // If cursor is before the insert start column on the same row, no lhs possible.
7256    if on_start_row && col <= mincol {
7257        return false;
7258    }
7259
7260    let iskeyword = ed.settings.iskeyword.clone();
7261    let abbrevs = ed.vim.abbrevs.clone();
7262
7263    let Some((lhs_len, rhs)) =
7264        try_abbrev_expand(&abbrevs, &line_before, mincol, trigger, &iskeyword)
7265    else {
7266        return false;
7267    };
7268
7269    // Delete `lhs_len` chars before the cursor.
7270    let lhs_start = col.saturating_sub(lhs_len);
7271    if lhs_len > 0 {
7272        ed.mutate_edit(Edit::DeleteRange {
7273            start: Position::new(row, lhs_start),
7274            end: Position::new(row, col),
7275            kind: hjkl_buffer::MotionKind::Char,
7276        });
7277    }
7278
7279    // Insert rhs at the (now updated) cursor position.
7280    let insert_pos = Position::new(row, lhs_start);
7281    if !rhs.is_empty() {
7282        ed.mutate_edit(Edit::InsertStr {
7283            at: insert_pos,
7284            text: rhs.clone(),
7285        });
7286    }
7287
7288    // Move cursor to end of inserted rhs.
7289    let new_col = lhs_start + rhs.chars().count();
7290    buf_set_cursor_rc(&mut ed.buffer, row, new_col);
7291    ed.push_buffer_cursor_to_textarea();
7292
7293    true
7294}
7295
7296fn word_text_object<H: crate::types::Host>(
7297    ed: &Editor<hjkl_buffer::Buffer, H>,
7298    inner: bool,
7299    big: bool,
7300) -> Option<((usize, usize), (usize, usize))> {
7301    let (row, col) = ed.cursor();
7302    let line = buf_line(&ed.buffer, row)?;
7303    let chars: Vec<char> = line.chars().collect();
7304    if chars.is_empty() {
7305        return None;
7306    }
7307    let at = col.min(chars.len().saturating_sub(1));
7308    let classify = |c: char| -> u8 {
7309        if c.is_whitespace() {
7310            0
7311        } else if big || is_wordchar(c) {
7312            1
7313        } else {
7314            2
7315        }
7316    };
7317    let cls = classify(chars[at]);
7318    let mut start = at;
7319    while start > 0 && classify(chars[start - 1]) == cls {
7320        start -= 1;
7321    }
7322    let mut end = at;
7323    while end + 1 < chars.len() && classify(chars[end + 1]) == cls {
7324        end += 1;
7325    }
7326    // Byte-offset helpers.
7327    let char_byte = |i: usize| {
7328        if i >= chars.len() {
7329            line.len()
7330        } else {
7331            line.char_indices().nth(i).map(|(b, _)| b).unwrap_or(0)
7332        }
7333    };
7334    let mut start_col = char_byte(start);
7335    // Exclusive end: byte index of char AFTER the last-included char.
7336    let mut end_col = char_byte(end + 1);
7337    if !inner {
7338        // `aw` — include trailing whitespace; if there's no trailing ws, absorb leading ws.
7339        let mut t = end + 1;
7340        let mut included_trailing = false;
7341        while t < chars.len() && chars[t].is_whitespace() {
7342            included_trailing = true;
7343            t += 1;
7344        }
7345        if included_trailing {
7346            end_col = char_byte(t);
7347        } else {
7348            let mut s = start;
7349            while s > 0 && chars[s - 1].is_whitespace() {
7350                s -= 1;
7351            }
7352            start_col = char_byte(s);
7353        }
7354    }
7355    Some(((row, start_col), (row, end_col)))
7356}
7357
7358fn quote_text_object<H: crate::types::Host>(
7359    ed: &Editor<hjkl_buffer::Buffer, H>,
7360    q: char,
7361    inner: bool,
7362) -> Option<((usize, usize), (usize, usize))> {
7363    let (row, col) = ed.cursor();
7364    let line = buf_line(&ed.buffer, row)?;
7365    let bytes = line.as_bytes();
7366    let q_byte = q as u8;
7367    // Find opening and closing quote on the same line.
7368    let mut positions: Vec<usize> = Vec::new();
7369    for (i, &b) in bytes.iter().enumerate() {
7370        if b == q_byte {
7371            positions.push(i);
7372        }
7373    }
7374    if positions.len() < 2 {
7375        return None;
7376    }
7377    let mut open_idx: Option<usize> = None;
7378    let mut close_idx: Option<usize> = None;
7379    for pair in positions.chunks(2) {
7380        if pair.len() < 2 {
7381            break;
7382        }
7383        if col >= pair[0] && col <= pair[1] {
7384            open_idx = Some(pair[0]);
7385            close_idx = Some(pair[1]);
7386            break;
7387        }
7388        if col < pair[0] {
7389            open_idx = Some(pair[0]);
7390            close_idx = Some(pair[1]);
7391            break;
7392        }
7393    }
7394    let open = open_idx?;
7395    let close = close_idx?;
7396    // End columns are *exclusive* — one past the last character to act on.
7397    if inner {
7398        if close <= open + 1 {
7399            return None;
7400        }
7401        Some(((row, open + 1), (row, close)))
7402    } else {
7403        // `da<q>` — "around" includes the surrounding whitespace on one
7404        // side: trailing whitespace if any exists after the closing quote;
7405        // otherwise leading whitespace before the opening quote. This
7406        // matches vim's `:help text-objects` behaviour and avoids leaving
7407        // a double-space when the quoted span sits mid-sentence.
7408        let after_close = close + 1; // byte index after closing quote
7409        if after_close < bytes.len() && bytes[after_close].is_ascii_whitespace() {
7410            // Eat trailing whitespace run.
7411            let mut end = after_close;
7412            while end < bytes.len() && bytes[end].is_ascii_whitespace() {
7413                end += 1;
7414            }
7415            Some(((row, open), (row, end)))
7416        } else if open > 0 && bytes[open - 1].is_ascii_whitespace() {
7417            // Eat leading whitespace run.
7418            let mut start = open;
7419            while start > 0 && bytes[start - 1].is_ascii_whitespace() {
7420                start -= 1;
7421            }
7422            Some(((row, start), (row, close + 1)))
7423        } else {
7424            Some(((row, open), (row, close + 1)))
7425        }
7426    }
7427}
7428
7429fn bracket_text_object<H: crate::types::Host>(
7430    ed: &Editor<hjkl_buffer::Buffer, H>,
7431    open: char,
7432    inner: bool,
7433    count: usize,
7434) -> Option<(Pos, Pos, RangeKind)> {
7435    let close = match open {
7436        '(' => ')',
7437        '[' => ']',
7438        '{' => '}',
7439        '<' => '>',
7440        _ => return None,
7441    };
7442    let (row, col) = ed.cursor();
7443    let lines = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
7444    let lines = lines.as_slice();
7445    // If the cursor sits ON the closing bracket, vim anchors the pair to that
7446    // bracket: the close is at the cursor and the open is found by scanning
7447    // backward from just before it. Without this, `find_open_bracket` counts
7448    // the cursor's own close, increments depth, and skips past its matching
7449    // open — making `di}`/`di{`-on-`}` a silent no-op.
7450    let cursor_char = lines.get(row).and_then(|l| l.chars().nth(col));
7451    let (open_pos, close_pos) = if cursor_char == Some(close) {
7452        let open_pos = if col > 0 {
7453            find_open_bracket(lines, row, col - 1, open, close)
7454        } else if row > 0 {
7455            let pr = row - 1;
7456            let pc = lines[pr].chars().count().saturating_sub(1);
7457            find_open_bracket(lines, pr, pc, open, close)
7458        } else {
7459            None
7460        }?;
7461        (open_pos, (row, col))
7462    } else {
7463        // Walk backward from cursor to find unbalanced opening. When the
7464        // cursor isn't inside any pair, fall back to scanning forward for
7465        // the next opening bracket (targets.vim-style: `ci(` works when
7466        // cursor is before the `(` on the same line or below).
7467        let open_pos = find_open_bracket(lines, row, col, open, close)
7468            .or_else(|| find_next_open(lines, row, col, open))?;
7469        let close_pos = find_close_bracket(lines, open_pos.0, open_pos.1 + 1, open, close)?;
7470        (open_pos, close_pos)
7471    };
7472    // Count: `2i{` / `2a{` target the Nth enclosing pair. Expand outward from
7473    // the innermost pair, re-anchoring to each enclosing bracket in turn. Stop
7474    // early (and use the outermost found) if there aren't `count` levels.
7475    let (open_pos, close_pos) = {
7476        let (mut op, mut cp) = (open_pos, close_pos);
7477        for _ in 1..count.max(1) {
7478            let outer = if op.1 > 0 {
7479                find_open_bracket(lines, op.0, op.1 - 1, open, close)
7480            } else if op.0 > 0 {
7481                let pr = op.0 - 1;
7482                let pc = lines[pr].chars().count().saturating_sub(1);
7483                find_open_bracket(lines, pr, pc, open, close)
7484            } else {
7485                None
7486            };
7487            let Some(oo) = outer else { break };
7488            let Some(oc) = find_close_bracket(lines, oo.0, oo.1 + 1, open, close) else {
7489                break;
7490            };
7491            op = oo;
7492            cp = oc;
7493        }
7494        (op, cp)
7495    };
7496    // End positions are *exclusive*.
7497    if inner {
7498        // The inner region is the raw charwise span from just after `{` to just
7499        // before `}`. Returned as Exclusive: the VISUAL path uses it directly
7500        // (so `vi{` is charwise — `vi{d` → "{}"), while the OPERATOR path
7501        // (`di{`/`ci{`) applies vim's exclusive-motion adjustment in
7502        // `apply_op_with_text_object` to collapse a contentful multi-line block
7503        // to bare braces ("{\n}") or promote a clean one to linewise.
7504        // Inner start = position just after `{`. When `{` is the last char on
7505        // its line, the inner region begins at the start of the next line (so
7506        // the exclusive-motion adjustment can promote to linewise). `advance_pos`
7507        // stops at end-of-line, so wrap explicitly here.
7508        let open_line_len = lines[open_pos.0].chars().count();
7509        let inner_start = if open_pos.1 + 1 >= open_line_len && open_pos.0 + 1 < lines.len() {
7510            (open_pos.0 + 1, 0)
7511        } else {
7512            advance_pos(lines, open_pos)
7513        };
7514        // Empty inner (`{}` / `( )` degenerate) → empty range at the inner
7515        // start. `di{` then no-ops; `ci{` inserts at that point.
7516        if inner_start.0 > close_pos.0
7517            || (inner_start.0 == close_pos.0 && inner_start.1 >= close_pos.1)
7518        {
7519            return Some((inner_start, inner_start, RangeKind::Exclusive));
7520        }
7521        // Whitespace-only multi-line inner: vim's `di{` is a no-op and `ci{`
7522        // inserts at the inner start without deleting the whitespace. Model as
7523        // an empty range at the inner start. Detected when every char strictly
7524        // between the braces (excluding newlines) is a space/tab, and there is
7525        // at least one — an inner of only newlines (empty lines) does NOT count
7526        // and falls through to the normal collapse.
7527        if close_pos.0 > open_pos.0 {
7528            let mut saw_ws = false;
7529            let mut saw_other = false;
7530            for r in inner_start.0..=close_pos.0 {
7531                let line: Vec<char> = lines
7532                    .get(r)
7533                    .map(|l| l.chars().collect())
7534                    .unwrap_or_default();
7535                let from = if r == inner_start.0 { inner_start.1 } else { 0 };
7536                let to = if r == close_pos.0 {
7537                    close_pos.1
7538                } else {
7539                    line.len()
7540                };
7541                for &c in line
7542                    .iter()
7543                    .take(to.min(line.len()))
7544                    .skip(from.min(line.len()))
7545                {
7546                    if c == ' ' || c == '\t' {
7547                        saw_ws = true;
7548                    } else {
7549                        saw_other = true;
7550                    }
7551                }
7552            }
7553            if saw_ws && !saw_other {
7554                return Some((inner_start, inner_start, RangeKind::Exclusive));
7555            }
7556        }
7557        Some((inner_start, close_pos, RangeKind::Exclusive))
7558    } else {
7559        Some((
7560            open_pos,
7561            advance_pos(lines, close_pos),
7562            RangeKind::Exclusive,
7563        ))
7564    }
7565}
7566
7567fn find_open_bracket(
7568    lines: &[String],
7569    row: usize,
7570    col: usize,
7571    open: char,
7572    close: char,
7573) -> Option<(usize, usize)> {
7574    let mut depth: i32 = 0;
7575    let mut r = row;
7576    let mut c = col as isize;
7577    loop {
7578        let cur = &lines[r];
7579        let chars: Vec<char> = cur.chars().collect();
7580        // Clamp `c` to the line length: callers may seed `col` past
7581        // EOL on virtual-cursor lines (e.g., insert mode after `o`)
7582        // so direct indexing would panic on empty / short lines.
7583        if (c as usize) >= chars.len() {
7584            c = chars.len() as isize - 1;
7585        }
7586        while c >= 0 {
7587            let ch = chars[c as usize];
7588            if ch == close {
7589                depth += 1;
7590            } else if ch == open {
7591                if depth == 0 {
7592                    return Some((r, c as usize));
7593                }
7594                depth -= 1;
7595            }
7596            c -= 1;
7597        }
7598        if r == 0 {
7599            return None;
7600        }
7601        r -= 1;
7602        c = lines[r].chars().count() as isize - 1;
7603    }
7604}
7605
7606fn find_close_bracket(
7607    lines: &[String],
7608    row: usize,
7609    start_col: usize,
7610    open: char,
7611    close: char,
7612) -> Option<(usize, usize)> {
7613    let mut depth: i32 = 0;
7614    let mut r = row;
7615    let mut c = start_col;
7616    loop {
7617        let cur = &lines[r];
7618        let chars: Vec<char> = cur.chars().collect();
7619        while c < chars.len() {
7620            let ch = chars[c];
7621            if ch == open {
7622                depth += 1;
7623            } else if ch == close {
7624                if depth == 0 {
7625                    return Some((r, c));
7626                }
7627                depth -= 1;
7628            }
7629            c += 1;
7630        }
7631        if r + 1 >= lines.len() {
7632            return None;
7633        }
7634        r += 1;
7635        c = 0;
7636    }
7637}
7638
7639/// Forward scan from `(row, col)` for the next occurrence of `open`.
7640/// Multi-line. Used by bracket text objects to support targets.vim-style
7641/// "search forward when not currently inside a pair" behaviour.
7642fn find_next_open(lines: &[String], row: usize, col: usize, open: char) -> Option<(usize, usize)> {
7643    let mut r = row;
7644    let mut c = col;
7645    while r < lines.len() {
7646        let chars: Vec<char> = lines[r].chars().collect();
7647        while c < chars.len() {
7648            if chars[c] == open {
7649                return Some((r, c));
7650            }
7651            c += 1;
7652        }
7653        r += 1;
7654        c = 0;
7655    }
7656    None
7657}
7658
7659fn advance_pos(lines: &[String], pos: (usize, usize)) -> (usize, usize) {
7660    let (r, c) = pos;
7661    let line_len = lines[r].chars().count();
7662    if c < line_len {
7663        (r, c + 1)
7664    } else if r + 1 < lines.len() {
7665        (r + 1, 0)
7666    } else {
7667        pos
7668    }
7669}
7670
7671fn paragraph_text_object<H: crate::types::Host>(
7672    ed: &Editor<hjkl_buffer::Buffer, H>,
7673    inner: bool,
7674) -> Option<((usize, usize), (usize, usize))> {
7675    let (row, _) = ed.cursor();
7676    let rope = crate::types::Query::rope(&ed.buffer);
7677    let n_lines = rope.len_lines();
7678    if n_lines == 0 {
7679        return None;
7680    }
7681    // A paragraph is a run of non-blank lines.
7682    let is_blank = |r: usize| -> bool {
7683        if r >= n_lines {
7684            return true;
7685        }
7686        rope_line_to_str(&rope, r).trim().is_empty()
7687    };
7688    if is_blank(row) {
7689        return None;
7690    }
7691    let mut top = row;
7692    while top > 0 && !is_blank(top - 1) {
7693        top -= 1;
7694    }
7695    let mut bot = row;
7696    while bot + 1 < n_lines && !is_blank(bot + 1) {
7697        bot += 1;
7698    }
7699    // For `ap`, include one trailing blank line if present.
7700    if !inner && bot + 1 < n_lines && is_blank(bot + 1) {
7701        bot += 1;
7702    }
7703    let end_col = rope_line_to_str(&rope, bot).chars().count();
7704    Some(((top, 0), (bot, end_col)))
7705}
7706
7707// ─── Individual commands ───────────────────────────────────────────────────
7708
7709/// Read the text in a vim-shaped range without mutating. Used by
7710/// `Operator::Yank` so we can pipe the same range translation as
7711/// [`cut_vim_range`] but skip the delete + inverse extraction.
7712fn read_vim_range<H: crate::types::Host>(
7713    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7714    start: (usize, usize),
7715    end: (usize, usize),
7716    kind: RangeKind,
7717) -> String {
7718    let (top, bot) = order(start, end);
7719    ed.sync_buffer_content_from_textarea();
7720    let rope = crate::types::Query::rope(&ed.buffer);
7721    let n_lines = rope.len_lines();
7722    match kind {
7723        RangeKind::Linewise => {
7724            let lo = top.0;
7725            let hi = bot.0.min(n_lines.saturating_sub(1));
7726            let mut text = rope_row_range_str(&rope, lo, hi);
7727            text.push('\n');
7728            text
7729        }
7730        RangeKind::Inclusive | RangeKind::Exclusive => {
7731            let inclusive = matches!(kind, RangeKind::Inclusive);
7732            // Walk row-by-row collecting chars in `[top, end_exclusive)`.
7733            let mut out = String::new();
7734            for row in top.0..=bot.0 {
7735                if row >= n_lines {
7736                    break;
7737                }
7738                let line = rope_line_to_str(&rope, row);
7739                let lo = if row == top.0 { top.1 } else { 0 };
7740                let hi_unclamped = if row == bot.0 {
7741                    if inclusive { bot.1 + 1 } else { bot.1 }
7742                } else {
7743                    line.chars().count() + 1
7744                };
7745                let row_chars: Vec<char> = line.chars().collect();
7746                let hi = hi_unclamped.min(row_chars.len());
7747                if lo < hi {
7748                    out.push_str(&row_chars[lo..hi].iter().collect::<String>());
7749                }
7750                if row < bot.0 {
7751                    out.push('\n');
7752                }
7753            }
7754            out
7755        }
7756    }
7757}
7758
7759/// Cut a vim-shaped range through the Buffer edit funnel and return
7760/// the deleted text. Translates vim's `RangeKind`
7761/// (Linewise/Inclusive/Exclusive) into the buffer's
7762/// `hjkl_buffer::MotionKind` (Line/Char) and applies the right end-
7763/// position adjustment so inclusive motions actually include the bot
7764/// cell. Pushes the cut text into the clipboard via `record_yank_to_host`
7765/// and the textarea yank buffer (still observed by `p`/`P` until the paste
7766/// path is ported), and updates `yank_linewise` for linewise cuts.
7767fn cut_vim_range<H: crate::types::Host>(
7768    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7769    start: (usize, usize),
7770    end: (usize, usize),
7771    kind: RangeKind,
7772) -> String {
7773    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
7774    let (top, bot) = order(start, end);
7775    ed.sync_buffer_content_from_textarea();
7776    let (buf_start, buf_end, buf_kind) = match kind {
7777        RangeKind::Linewise => (
7778            Position::new(top.0, 0),
7779            Position::new(bot.0, 0),
7780            BufKind::Line,
7781        ),
7782        RangeKind::Inclusive => {
7783            let line_chars = buf_line_chars(&ed.buffer, bot.0);
7784            // Advance one cell past `bot` so the buffer's exclusive
7785            // `cut_chars` actually drops the inclusive endpoint. Wrap
7786            // to the next row when bot already sits on the last char.
7787            let next = if bot.1 < line_chars {
7788                Position::new(bot.0, bot.1 + 1)
7789            } else if bot.0 + 1 < buf_row_count(&ed.buffer) {
7790                Position::new(bot.0 + 1, 0)
7791            } else {
7792                Position::new(bot.0, line_chars)
7793            };
7794            (Position::new(top.0, top.1), next, BufKind::Char)
7795        }
7796        RangeKind::Exclusive => (
7797            Position::new(top.0, top.1),
7798            Position::new(bot.0, bot.1),
7799            BufKind::Char,
7800        ),
7801    };
7802    let inverse = ed.mutate_edit(Edit::DeleteRange {
7803        start: buf_start,
7804        end: buf_end,
7805        kind: buf_kind,
7806    });
7807    let text = match inverse {
7808        Edit::InsertStr { text, .. } => text,
7809        _ => String::new(),
7810    };
7811    if !text.is_empty() {
7812        ed.record_yank_to_host(text.clone());
7813        ed.record_delete(text.clone(), matches!(kind, RangeKind::Linewise));
7814    }
7815    ed.push_buffer_cursor_to_textarea();
7816    text
7817}
7818
7819/// `D` / `C` — delete from cursor to end of line through the edit
7820/// funnel. Pushes the deleted text to the clipboard via `record_yank_to_host`
7821/// and the textarea's yank buffer (still observed by `p`/`P` until the paste
7822/// path is ported). Cursor lands at the deletion start so the caller
7823/// can decide whether to step it left (`D`) or open insert mode (`C`).
7824fn delete_to_eol<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
7825    use hjkl_buffer::{Edit, MotionKind, Position};
7826    ed.sync_buffer_content_from_textarea();
7827    let cursor = buf_cursor_pos(&ed.buffer);
7828    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
7829    if cursor.col >= line_chars {
7830        return;
7831    }
7832    let inverse = ed.mutate_edit(Edit::DeleteRange {
7833        start: cursor,
7834        end: Position::new(cursor.row, line_chars),
7835        kind: MotionKind::Char,
7836    });
7837    if let Edit::InsertStr { text, .. } = inverse
7838        && !text.is_empty()
7839    {
7840        ed.record_yank_to_host(text.clone());
7841        ed.vim.yank_linewise = false;
7842        ed.set_yank(text);
7843    }
7844    buf_set_cursor_pos(&mut ed.buffer, cursor);
7845    ed.push_buffer_cursor_to_textarea();
7846}
7847
7848fn do_char_delete<H: crate::types::Host>(
7849    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7850    forward: bool,
7851    count: usize,
7852) {
7853    use hjkl_buffer::{Edit, MotionKind, Position};
7854    ed.push_undo();
7855    ed.sync_buffer_content_from_textarea();
7856    // Collect deleted chars so we can write them to the unnamed register
7857    // (vim's `x`/`X` populate `"` so that `xp` round-trips the char).
7858    let mut deleted = String::new();
7859    for _ in 0..count {
7860        let cursor = buf_cursor_pos(&ed.buffer);
7861        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
7862        if forward {
7863            // `x` — delete the char under the cursor. Vim no-ops on
7864            // an empty line; the buffer would drop a row otherwise.
7865            if cursor.col >= line_chars {
7866                continue;
7867            }
7868            let inverse = ed.mutate_edit(Edit::DeleteRange {
7869                start: cursor,
7870                end: Position::new(cursor.row, cursor.col + 1),
7871                kind: MotionKind::Char,
7872            });
7873            if let Edit::InsertStr { text, .. } = inverse {
7874                deleted.push_str(&text);
7875            }
7876        } else {
7877            // `X` — delete the char before the cursor.
7878            if cursor.col == 0 {
7879                continue;
7880            }
7881            let inverse = ed.mutate_edit(Edit::DeleteRange {
7882                start: Position::new(cursor.row, cursor.col - 1),
7883                end: cursor,
7884                kind: MotionKind::Char,
7885            });
7886            if let Edit::InsertStr { text, .. } = inverse {
7887                // X deletes backwards; prepend so the register text
7888                // matches reading order (first deleted char first).
7889                deleted = text + &deleted;
7890            }
7891        }
7892    }
7893    if !deleted.is_empty() {
7894        ed.record_yank_to_host(deleted.clone());
7895        ed.record_delete(deleted, false);
7896    }
7897    ed.push_buffer_cursor_to_textarea();
7898}
7899
7900/// Vim `Ctrl-a` / `Ctrl-x` — find the next number at or after the cursor on the
7901/// current line, add `delta`, leave the cursor on the last digit of the result.
7902/// Recognises `0x`/`0X` hex literals (incremented in hex, width preserved) as
7903/// well as signed decimals. No-op if the line has no number to the right.
7904pub(crate) fn adjust_number<H: crate::types::Host>(
7905    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7906    delta: i64,
7907) -> bool {
7908    use hjkl_buffer::{Edit, MotionKind, Position};
7909    ed.sync_buffer_content_from_textarea();
7910    let cursor = buf_cursor_pos(&ed.buffer);
7911    let row = cursor.row;
7912    let chars: Vec<char> = match buf_line(&ed.buffer, row) {
7913        Some(l) => l.chars().collect(),
7914        None => return false,
7915    };
7916    let len = chars.len();
7917
7918    // Scan from the cursor for the start of the leftmost number — a `0x`/`0X`
7919    // hex literal takes priority over a bare decimal at the same position.
7920    let is_hex_prefix = |i: usize| {
7921        chars[i] == '0'
7922            && i + 1 < len
7923            && matches!(chars[i + 1], 'x' | 'X')
7924            && chars.get(i + 2).is_some_and(|c| c.is_ascii_hexdigit())
7925    };
7926    let mut i = cursor.col;
7927    let mut hex = false;
7928    loop {
7929        if i >= len {
7930            return false;
7931        }
7932        if is_hex_prefix(i) {
7933            hex = true;
7934            break;
7935        }
7936        if chars[i].is_ascii_digit() {
7937            break;
7938        }
7939        i += 1;
7940    }
7941
7942    let (span_start, span_end, new_s) = if hex {
7943        // `0x` + hex digits. Increment the value, preserve the digit width.
7944        let digits_start = i + 2;
7945        let mut digits_end = digits_start;
7946        while digits_end < len && chars[digits_end].is_ascii_hexdigit() {
7947            digits_end += 1;
7948        }
7949        let hexs: String = chars[digits_start..digits_end].iter().collect();
7950        let Ok(n) = u64::from_str_radix(&hexs, 16) else {
7951            return false;
7952        };
7953        let new_val = (n as i128 + delta as i128).max(0) as u64;
7954        let width = digits_end - digits_start;
7955        let prefix: String = chars[i..digits_start].iter().collect();
7956        (i, digits_end, format!("{prefix}{new_val:0width$x}"))
7957    } else {
7958        // Signed decimal.
7959        let digit_start = i;
7960        let span_start = if digit_start > 0 && chars[digit_start - 1] == '-' {
7961            digit_start - 1
7962        } else {
7963            digit_start
7964        };
7965        let mut span_end = digit_start;
7966        while span_end < len && chars[span_end].is_ascii_digit() {
7967            span_end += 1;
7968        }
7969        let s: String = chars[span_start..span_end].iter().collect();
7970        let Ok(n) = s.parse::<i64>() else {
7971            return false;
7972        };
7973        (span_start, span_end, n.saturating_add(delta).to_string())
7974    };
7975
7976    ed.push_undo();
7977    let span_start_pos = Position::new(row, span_start);
7978    let span_end_pos = Position::new(row, span_end);
7979    ed.mutate_edit(Edit::DeleteRange {
7980        start: span_start_pos,
7981        end: span_end_pos,
7982        kind: MotionKind::Char,
7983    });
7984    ed.mutate_edit(Edit::InsertStr {
7985        at: span_start_pos,
7986        text: new_s.clone(),
7987    });
7988    let new_len = new_s.chars().count();
7989    buf_set_cursor_rc(&mut ed.buffer, row, span_start + new_len.saturating_sub(1));
7990    ed.push_buffer_cursor_to_textarea();
7991    true
7992}
7993
7994pub(crate) fn replace_char<H: crate::types::Host>(
7995    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7996    ch: char,
7997    count: usize,
7998) {
7999    use hjkl_buffer::{Edit, MotionKind, Position};
8000    ed.push_undo();
8001    ed.sync_buffer_content_from_textarea();
8002    for _ in 0..count {
8003        let cursor = buf_cursor_pos(&ed.buffer);
8004        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
8005        if cursor.col >= line_chars {
8006            break;
8007        }
8008        ed.mutate_edit(Edit::DeleteRange {
8009            start: cursor,
8010            end: Position::new(cursor.row, cursor.col + 1),
8011            kind: MotionKind::Char,
8012        });
8013        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
8014    }
8015    // Vim leaves the cursor on the last replaced char.
8016    crate::motions::move_left(&mut ed.buffer, 1);
8017    ed.push_buffer_cursor_to_textarea();
8018}
8019
8020fn toggle_case_at_cursor<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
8021    use hjkl_buffer::{Edit, MotionKind, Position};
8022    ed.sync_buffer_content_from_textarea();
8023    let cursor = buf_cursor_pos(&ed.buffer);
8024    let Some(c) = buf_line(&ed.buffer, cursor.row).and_then(|l| l.chars().nth(cursor.col)) else {
8025        return;
8026    };
8027    let toggled = if c.is_uppercase() {
8028        c.to_lowercase().next().unwrap_or(c)
8029    } else {
8030        c.to_uppercase().next().unwrap_or(c)
8031    };
8032    ed.mutate_edit(Edit::DeleteRange {
8033        start: cursor,
8034        end: Position::new(cursor.row, cursor.col + 1),
8035        kind: MotionKind::Char,
8036    });
8037    ed.mutate_edit(Edit::InsertChar {
8038        at: cursor,
8039        ch: toggled,
8040    });
8041}
8042
8043fn join_line<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
8044    use hjkl_buffer::{Edit, Position};
8045    ed.sync_buffer_content_from_textarea();
8046    let row = buf_cursor_pos(&ed.buffer).row;
8047    if row + 1 >= buf_row_count(&ed.buffer) {
8048        return;
8049    }
8050    let cur_line = buf_line(&ed.buffer, row).unwrap_or_default();
8051    let next_raw = buf_line(&ed.buffer, row + 1).unwrap_or_default();
8052    let next_trimmed = next_raw.trim_start();
8053    let cur_chars = cur_line.chars().count();
8054    let next_chars = next_raw.chars().count();
8055    // `J` inserts a single space iff both sides are non-empty after
8056    // stripping the next line's leading whitespace.
8057    let separator = if !cur_line.is_empty() && !next_trimmed.is_empty() {
8058        " "
8059    } else {
8060        ""
8061    };
8062    let joined = format!("{cur_line}{separator}{next_trimmed}");
8063    ed.mutate_edit(Edit::Replace {
8064        start: Position::new(row, 0),
8065        end: Position::new(row + 1, next_chars),
8066        with: joined,
8067    });
8068    // Vim parks the cursor on the inserted space — or at the join
8069    // point when no space went in (which is the same column either
8070    // way, since the space sits exactly at `cur_chars`).
8071    buf_set_cursor_rc(&mut ed.buffer, row, cur_chars);
8072    ed.push_buffer_cursor_to_textarea();
8073}
8074
8075/// `gJ` — join the next line onto the current one without inserting a
8076/// separating space or stripping leading whitespace.
8077fn join_line_raw<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
8078    use hjkl_buffer::Edit;
8079    ed.sync_buffer_content_from_textarea();
8080    let row = buf_cursor_pos(&ed.buffer).row;
8081    if row + 1 >= buf_row_count(&ed.buffer) {
8082        return;
8083    }
8084    let join_col = buf_line_chars(&ed.buffer, row);
8085    ed.mutate_edit(Edit::JoinLines {
8086        row,
8087        count: 1,
8088        with_space: false,
8089    });
8090    // Vim leaves the cursor at the join point (end of original line).
8091    buf_set_cursor_rc(&mut ed.buffer, row, join_col);
8092    ed.push_buffer_cursor_to_textarea();
8093}
8094
8095/// Visual-mode `J` (`with_space = true`) / `gJ` (`with_space = false`) — join
8096/// every line spanned by the selection into one. A single-line selection joins
8097/// the current line with the one below (matching normal-mode `J`).
8098pub(crate) fn visual_join<H: crate::types::Host>(
8099    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8100    with_space: bool,
8101) {
8102    let cursor_row = buf_cursor_pos(&ed.buffer).row;
8103    let (top, bot) = match ed.vim.mode {
8104        Mode::VisualLine => (
8105            cursor_row.min(ed.vim.visual_line_anchor),
8106            cursor_row.max(ed.vim.visual_line_anchor),
8107        ),
8108        Mode::VisualBlock => {
8109            let a = ed.vim.block_anchor.0;
8110            (a.min(cursor_row), a.max(cursor_row))
8111        }
8112        Mode::Visual => {
8113            let a = ed.vim.visual_anchor.0;
8114            (a.min(cursor_row), a.max(cursor_row))
8115        }
8116        _ => return,
8117    };
8118    // N selected lines → N-1 joins; a single line still does one join (with the
8119    // line below) like normal-mode `J`.
8120    let joins = (bot - top).max(1);
8121    ed.push_undo();
8122    buf_set_cursor_rc(&mut ed.buffer, top, 0);
8123    ed.push_buffer_cursor_to_textarea();
8124    for _ in 0..joins {
8125        if with_space {
8126            join_line(ed);
8127        } else {
8128            join_line_raw(ed);
8129        }
8130    }
8131    ed.vim.mode = Mode::Normal;
8132    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
8133}
8134
8135/// `[count]%` — go to the line at `count` percent of the file (vim: line
8136/// `(count * line_count + 99) / 100`), cursor on the first non-blank.
8137pub(crate) fn goto_percent<H: crate::types::Host>(
8138    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8139    count: usize,
8140) {
8141    let rows = buf_row_count(&ed.buffer);
8142    if rows == 0 {
8143        return;
8144    }
8145    // Exclude the phantom trailing empty line (a file ending in `\n` is N lines
8146    // in vim, not N+1) so the percentage matches nvim.
8147    let total = if rows >= 2
8148        && buf_line(&ed.buffer, rows - 1)
8149            .map(|s| s.is_empty())
8150            .unwrap_or(false)
8151    {
8152        rows - 1
8153    } else {
8154        rows
8155    };
8156    // 1-based target line, clamped to the buffer (vim: ceil(count*lines/100)).
8157    let line = (count * total).div_ceil(100).clamp(1, total);
8158    let pre = ed.cursor();
8159    ed.jump_cursor(line - 1, 0);
8160    move_first_non_whitespace(ed);
8161    ed.sticky_col = Some(ed.cursor().1);
8162    if ed.cursor() != pre {
8163        ed.push_jump(pre);
8164    }
8165}
8166
8167/// Indent width of a leading-whitespace prefix, counting a `\t` as advancing
8168/// to the next `tabstop` boundary and a space as one column.
8169fn indent_width(s: &str, tabstop: usize) -> usize {
8170    let ts = tabstop.max(1);
8171    let mut w = 0usize;
8172    for c in s.chars() {
8173        match c {
8174            ' ' => w += 1,
8175            '\t' => w += ts - (w % ts),
8176            _ => break,
8177        }
8178    }
8179    w
8180}
8181
8182/// Build a leading-whitespace string of `width` columns honoring `expandtab`
8183/// (spaces) vs `noexpandtab` (tabs for full `tabstop` runs, spaces remainder).
8184fn build_indent(width: usize, settings: &crate::editor::Settings) -> String {
8185    if settings.expandtab {
8186        return " ".repeat(width);
8187    }
8188    let ts = settings.tabstop.max(1);
8189    let tabs = width / ts;
8190    let spaces = width % ts;
8191    format!("{}{}", "\t".repeat(tabs), " ".repeat(spaces))
8192}
8193
8194/// `]p` / `[p` reindent: shift every line of `text` so the FIRST line's indent
8195/// matches `target_width` columns; later lines keep their relative offset.
8196fn reindent_block(text: &str, target_width: usize, settings: &crate::editor::Settings) -> String {
8197    let ts = settings.tabstop.max(1);
8198    let lines: Vec<&str> = text.split('\n').collect();
8199    let first_width = lines.first().map(|l| indent_width(l, ts)).unwrap_or(0);
8200    let delta = target_width as isize - first_width as isize;
8201    lines
8202        .iter()
8203        .map(|line| {
8204            let trimmed = line.trim_start_matches([' ', '\t']);
8205            if trimmed.is_empty() {
8206                // Preserve blank lines as truly empty (vim does not indent them).
8207                return String::new();
8208            }
8209            let old_w = indent_width(line, ts) as isize;
8210            let new_w = (old_w + delta).max(0) as usize;
8211            format!("{}{}", build_indent(new_w, settings), trimmed)
8212        })
8213        .collect::<Vec<_>>()
8214        .join("\n")
8215}
8216
8217fn do_paste<H: crate::types::Host>(
8218    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8219    before: bool,
8220    count: usize,
8221    cursor_after: bool,
8222    reindent: bool,
8223) {
8224    use hjkl_buffer::{Edit, Position};
8225    ed.push_undo();
8226    // Resolve the source register: `"reg` prefix (consumed) or the
8227    // unnamed register otherwise. Read text + linewise from the
8228    // selected slot rather than the global `vim.yank_linewise` so
8229    // pasting from `"0` after a delete still uses the yank's layout.
8230    let selector = ed.vim.pending_register.take();
8231    let (yank, linewise) = {
8232        let regs = ed.registers();
8233        match selector.and_then(|c| regs.read(c)) {
8234            Some(slot) => (slot.text.clone(), slot.linewise),
8235            // Read both fields from the unnamed slot rather than mixing the
8236            // slot's text with `vim.yank_linewise`. The cached vim flag is
8237            // per-editor, so a register imported from another editor (e.g.
8238            // cross-buffer yank/paste) carried the wrong linewise without
8239            // this — pasting a linewise yank inserted at the char cursor.
8240            None => (regs.unnamed.text.clone(), regs.unnamed.linewise),
8241        }
8242    };
8243    // Vim `:h '[` / `:h ']`: after paste `[` = first inserted char of
8244    // the final paste, `]` = last inserted char of the final paste.
8245    // We track (lo, hi) across iterations; the last value wins.
8246    let mut paste_mark: Option<((usize, usize), (usize, usize))> = None;
8247    // Capture the cursor row before any paste iterations. Vim's
8248    // linewise `[count]p` lands the cursor on the FIRST pasted line
8249    // (original_row + 1), not on the last iteration's paste row.
8250    // Without this snapshot the per-iteration cursor advancement leaves
8251    // the cursor at `original_row + count` instead.
8252    let original_row_for_linewise_after = if linewise && !before {
8253        // Fold-aware: `p` on a closed fold pastes after the fold, so the first
8254        // pasted line is `fold_end + 1`, not `cursor_row + 1`.
8255        let r = buf_cursor_pos(&ed.buffer).row;
8256        let (_, fold_end) = expand_linewise_over_closed_folds(&ed.buffer, r, r);
8257        Some(fold_end)
8258    } else {
8259        None
8260    };
8261    for _ in 0..count {
8262        ed.sync_buffer_content_from_textarea();
8263        let yank = yank.clone();
8264        if yank.is_empty() {
8265            continue;
8266        }
8267        if linewise {
8268            // Linewise paste: insert payload as fresh row(s) above
8269            // (`P`) or below (`p`) the cursor's row. Cursor lands on
8270            // the first non-blank of the first pasted line.
8271            let mut text = yank.trim_matches('\n').to_string();
8272            let row = buf_cursor_pos(&ed.buffer).row;
8273            // `]p` / `[p` — reindent the pasted block to the current line.
8274            if reindent {
8275                let cur_line = buf_line(&ed.buffer, row).unwrap_or_default();
8276                let target_w = indent_width(&cur_line, ed.settings.tabstop.max(1));
8277                text = reindent_block(&text, target_w, &ed.settings);
8278            }
8279            // Fold-aware: linewise paste lands relative to the whole CLOSED
8280            // fold, not just the cursor line — `p` after the fold's last row,
8281            // `P` before its first row (vim behaviour). No fold → unchanged.
8282            let (fold_start, fold_end) = expand_linewise_over_closed_folds(&ed.buffer, row, row);
8283            let target_row = if before {
8284                ed.mutate_edit(Edit::InsertStr {
8285                    at: Position::new(fold_start, 0),
8286                    text: format!("{text}\n"),
8287                });
8288                fold_start
8289            } else {
8290                let line_chars = buf_line_chars(&ed.buffer, fold_end);
8291                ed.mutate_edit(Edit::InsertStr {
8292                    at: Position::new(fold_end, line_chars),
8293                    text: format!("\n{text}"),
8294                });
8295                fold_end + 1
8296            };
8297            buf_set_cursor_rc(&mut ed.buffer, target_row, 0);
8298            crate::motions::move_first_non_blank(&mut ed.buffer);
8299            ed.push_buffer_cursor_to_textarea();
8300            // Linewise: `[` = (target_row, 0), `]` = (bot_row, last_col).
8301            let payload_lines = text.lines().count().max(1);
8302            let bot_row = target_row + payload_lines - 1;
8303            let bot_last_col = buf_line_chars(&ed.buffer, bot_row).saturating_sub(1);
8304            paste_mark = Some(((target_row, 0), (bot_row, bot_last_col)));
8305        } else {
8306            // Charwise paste. `P` inserts at cursor (shifting cell
8307            // right); `p` inserts after cursor (advance one cell
8308            // first, clamped to the end of the line).
8309            let cursor = buf_cursor_pos(&ed.buffer);
8310            let at = if before {
8311                cursor
8312            } else {
8313                let line_chars = buf_line_chars(&ed.buffer, cursor.row);
8314                Position::new(cursor.row, (cursor.col + 1).min(line_chars))
8315            };
8316            ed.mutate_edit(Edit::InsertStr {
8317                at,
8318                text: yank.clone(),
8319            });
8320            // Vim parks the cursor on the last char of the pasted text
8321            // (do_insert_str leaves it one past the end). `gp` instead
8322            // leaves the cursor just AFTER the pasted text, so skip the
8323            // step-back there.
8324            if !cursor_after && ed.cursor().1 > 0 {
8325                crate::motions::move_left(&mut ed.buffer, 1);
8326                ed.push_buffer_cursor_to_textarea();
8327            }
8328            // Charwise: `[` = insert start, `]` = last pasted char.
8329            let lo = (at.row, at.col);
8330            let hi = if cursor_after {
8331                let c = ed.cursor();
8332                (c.0, c.1.saturating_sub(1))
8333            } else {
8334                ed.cursor()
8335            };
8336            paste_mark = Some((lo, hi));
8337        }
8338    }
8339    if let Some((lo, hi)) = paste_mark {
8340        ed.set_mark('[', lo);
8341        ed.set_mark(']', hi);
8342    }
8343    // `gp` / `gP` linewise: cursor lands on the line just AFTER the pasted
8344    // block (the `]` mark's row + 1), at column 0, clamped to the last row.
8345    if cursor_after && linewise {
8346        if let Some((_, (bot_row, _))) = paste_mark {
8347            let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
8348            let target = (bot_row + 1).min(last_row);
8349            buf_set_cursor_rc(&mut ed.buffer, target, 0);
8350            ed.push_buffer_cursor_to_textarea();
8351        }
8352    } else if let Some(orig_row) = original_row_for_linewise_after {
8353        // Linewise `p` (after) with count: cursor lands on the FIRST pasted
8354        // line (original_row + 1) — vim parity. The per-iteration loop
8355        // moves cursor to each paste's target_row, so without this reset
8356        // `5p` would land at original_row + 5 instead of original_row + 1.
8357        let first_target = orig_row.saturating_add(1);
8358        buf_set_cursor_rc(&mut ed.buffer, first_target, 0);
8359        crate::motions::move_first_non_blank(&mut ed.buffer);
8360        ed.push_buffer_cursor_to_textarea();
8361    }
8362    // Any paste re-anchors the sticky column to the new cursor position.
8363    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
8364}
8365
8366/// Visual-mode `p` / `P` — replace the active selection with the register.
8367/// With `p` the deleted selection lands in the unnamed register (vim's swap);
8368/// with `P` (`before = true`) the source register is preserved so it can be
8369/// pasted over multiple selections in turn.
8370pub(crate) fn visual_paste<H: crate::types::Host>(
8371    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8372    before: bool,
8373) {
8374    use hjkl_buffer::{Edit, Position};
8375    ed.sync_buffer_content_from_textarea();
8376
8377    // Resolve the source register (selector or unnamed) BEFORE the delete
8378    // overwrites the unnamed register with the cut selection.
8379    let selector = ed.vim.pending_register.take();
8380    let (reg_text, reg_linewise) = {
8381        let regs = ed.registers();
8382        match selector.and_then(|c| regs.read(c)) {
8383            Some(slot) => (slot.text.clone(), slot.linewise),
8384            None => (regs.unnamed.text.clone(), regs.unnamed.linewise),
8385        }
8386    };
8387    // For `P`, snapshot the unnamed register so we can restore it afterwards.
8388    let saved_unnamed = before.then(|| ed.registers().unnamed.clone());
8389
8390    let mode = ed.vim.mode;
8391    ed.push_undo();
8392
8393    match mode {
8394        Mode::VisualLine => {
8395            let cursor_row = buf_cursor_pos(&ed.buffer).row;
8396            let top = cursor_row.min(ed.vim.visual_line_anchor);
8397            let bot = cursor_row.max(ed.vim.visual_line_anchor);
8398            // Delete the selected lines into the unnamed register.
8399            cut_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
8400            // Insert the register as fresh line(s) where the selection was.
8401            let text = reg_text.trim_matches('\n').to_string();
8402            let line_count = buf_row_count(&ed.buffer);
8403            if top >= line_count {
8404                // Selection reached the end of the buffer: append below the
8405                // (new) last line.
8406                let last = line_count.saturating_sub(1);
8407                let lc = buf_line_chars(&ed.buffer, last);
8408                ed.mutate_edit(Edit::InsertStr {
8409                    at: Position::new(last, lc),
8410                    text: format!("\n{text}"),
8411                });
8412                buf_set_cursor_rc(&mut ed.buffer, last + 1, 0);
8413            } else {
8414                ed.mutate_edit(Edit::InsertStr {
8415                    at: Position::new(top, 0),
8416                    text: format!("{text}\n"),
8417                });
8418                buf_set_cursor_rc(&mut ed.buffer, top, 0);
8419            }
8420            crate::motions::move_first_non_blank(&mut ed.buffer);
8421            ed.push_buffer_cursor_to_textarea();
8422        }
8423        Mode::Visual | Mode::VisualBlock => {
8424            let anchor = if mode == Mode::VisualBlock {
8425                ed.vim.block_anchor
8426            } else {
8427                ed.vim.visual_anchor
8428            };
8429            let cursor = ed.cursor();
8430            let (top, bot) = order(anchor, cursor);
8431            // Delete the selection into the unnamed register.
8432            cut_vim_range(ed, top, bot, RangeKind::Inclusive);
8433            // Insert the register text where the selection started.
8434            if reg_linewise {
8435                // Linewise register into a charwise hole: open a line below.
8436                let text = reg_text.trim_matches('\n').to_string();
8437                let lc = buf_line_chars(&ed.buffer, top.0);
8438                ed.mutate_edit(Edit::InsertStr {
8439                    at: Position::new(top.0, lc),
8440                    text: format!("\n{text}"),
8441                });
8442                buf_set_cursor_rc(&mut ed.buffer, top.0 + 1, 0);
8443                crate::motions::move_first_non_blank(&mut ed.buffer);
8444            } else {
8445                ed.mutate_edit(Edit::InsertStr {
8446                    at: Position::new(top.0, top.1),
8447                    text: reg_text.clone(),
8448                });
8449                // Park the cursor on the last char of the inserted text.
8450                let inserted_len = reg_text.chars().count();
8451                let last_col = top.1 + inserted_len.saturating_sub(1);
8452                buf_set_cursor_rc(&mut ed.buffer, top.0, last_col);
8453            }
8454            ed.push_buffer_cursor_to_textarea();
8455        }
8456        _ => {}
8457    }
8458
8459    // `P` preserves the source register; restore the snapshot.
8460    if let Some(slot) = saved_unnamed {
8461        ed.registers_mut().unnamed = slot;
8462    }
8463    ed.vim.mode = Mode::Normal;
8464    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
8465}
8466
8467/// Visual-mode `<C-a>` / `<C-x>` and `g<C-a>` / `g<C-x>`. Adds `delta` to the
8468/// first number on each selected line. When `sequential` is true the increment
8469/// grows by `delta` for each successive number found (vim's `g<C-a>`): the
8470/// first gets `delta`, the second `2*delta`, and so on.
8471pub(crate) fn adjust_number_visual<H: crate::types::Host>(
8472    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8473    delta: i64,
8474    sequential: bool,
8475) {
8476    use hjkl_buffer::{Edit, MotionKind, Position};
8477    ed.sync_buffer_content_from_textarea();
8478    let mode = ed.vim.mode;
8479    let cursor = buf_cursor_pos(&ed.buffer);
8480
8481    // Resolve the row range + the per-row start column to scan from.
8482    let (top, bot, mut scan_col_first, block_left) = match mode {
8483        Mode::VisualLine => {
8484            let t = cursor.row.min(ed.vim.visual_line_anchor);
8485            let b = cursor.row.max(ed.vim.visual_line_anchor);
8486            (t, b, 0usize, None)
8487        }
8488        Mode::Visual => {
8489            let (a, c) = order(ed.vim.visual_anchor, (cursor.row, cursor.col));
8490            (a.0, c.0, a.1, None)
8491        }
8492        Mode::VisualBlock => {
8493            let (a, c) = order(ed.vim.block_anchor, (cursor.row, cursor.col));
8494            let left = a.1.min(c.1);
8495            (a.0, c.0, left, Some(left))
8496        }
8497        _ => return,
8498    };
8499
8500    ed.push_undo();
8501    let mut found_count: i64 = 0;
8502    for row in top..=bot {
8503        let start_col = match block_left {
8504            Some(left) => left,
8505            None => {
8506                // First row of a charwise selection starts at the anchor/cursor
8507                // column; subsequent rows start at column 0.
8508                let c = if row == top { scan_col_first } else { 0 };
8509                scan_col_first = 0;
8510                c
8511            }
8512        };
8513        let chars: Vec<char> = match buf_line(&ed.buffer, row) {
8514            Some(l) => l.chars().collect(),
8515            None => continue,
8516        };
8517        let Some(digit_start) =
8518            (start_col.min(chars.len())..chars.len()).find(|&i| chars[i].is_ascii_digit())
8519        else {
8520            continue;
8521        };
8522        let span_start = if digit_start > 0 && chars[digit_start - 1] == '-' {
8523            digit_start - 1
8524        } else {
8525            digit_start
8526        };
8527        let mut span_end = digit_start;
8528        while span_end < chars.len() && chars[span_end].is_ascii_digit() {
8529            span_end += 1;
8530        }
8531        let s: String = chars[span_start..span_end].iter().collect();
8532        let Ok(n) = s.parse::<i64>() else {
8533            continue;
8534        };
8535        found_count += 1;
8536        let this_delta = if sequential {
8537            delta.saturating_mul(found_count)
8538        } else {
8539            delta
8540        };
8541        let new_s = n.saturating_add(this_delta).to_string();
8542        let span_start_pos = Position::new(row, span_start);
8543        let span_end_pos = Position::new(row, span_end);
8544        ed.mutate_edit(Edit::DeleteRange {
8545            start: span_start_pos,
8546            end: span_end_pos,
8547            kind: MotionKind::Char,
8548        });
8549        ed.mutate_edit(Edit::InsertStr {
8550            at: span_start_pos,
8551            text: new_s,
8552        });
8553    }
8554    // Vim leaves the cursor at the start of the selection.
8555    buf_set_cursor_rc(&mut ed.buffer, top, block_left.unwrap_or(0));
8556    ed.push_buffer_cursor_to_textarea();
8557    ed.vim.mode = Mode::Normal;
8558    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
8559}
8560
8561pub(crate) fn do_undo<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
8562    if let Some(entry) = ed.buffer.pop_undo_entry() {
8563        let (cur_rope, cur_cursor) = ed.snapshot();
8564        ed.buffer.push_redo_entry(hjkl_buffer::UndoEntry {
8565            rope: cur_rope,
8566            cursor: cur_cursor,
8567            timestamp: entry.timestamp,
8568        });
8569        ed.restore_rope(entry.rope, entry.cursor);
8570    }
8571    ed.vim.mode = Mode::Normal;
8572    // The restored cursor came from a snapshot taken in insert mode
8573    // (before the insert started) and may be past the last valid
8574    // normal-mode column. Clamp it now, same as Esc-from-insert does.
8575    clamp_cursor_to_normal_mode(ed);
8576}
8577
8578pub(crate) fn do_redo<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
8579    if let Some(entry) = ed.buffer.pop_redo_entry() {
8580        let (cur_rope, cur_cursor) = ed.snapshot();
8581        let before = cur_rope.clone();
8582        ed.buffer.push_undo_entry(hjkl_buffer::UndoEntry {
8583            rope: cur_rope,
8584            cursor: cur_cursor,
8585            timestamp: entry.timestamp,
8586        });
8587        ed.cap_undo();
8588        ed.restore_rope(entry.rope, entry.cursor);
8589        // vim parks the cursor at the START of the reapplied change, not the
8590        // end-of-insert position stored in the redo snapshot. Recompute it from
8591        // the first character that differs between the pre- and post-redo text.
8592        let after = crate::types::Query::rope(&ed.buffer);
8593        if let Some((row, col)) = first_diff_pos(&before, &after) {
8594            buf_set_cursor_rc(&mut ed.buffer, row, col);
8595            ed.push_buffer_cursor_to_textarea();
8596        }
8597    }
8598    ed.vim.mode = Mode::Normal;
8599    clamp_cursor_to_normal_mode(ed);
8600}
8601
8602/// First `(row, col)` where two ropes differ, or `None` if identical. Used to
8603/// place the cursor at the start of a redone change (vim parity).
8604fn first_diff_pos(a: &ropey::Rope, b: &ropey::Rope) -> Option<(usize, usize)> {
8605    let rows = a.len_lines().max(b.len_lines());
8606    for r in 0..rows {
8607        let la = if r < a.len_lines() {
8608            hjkl_buffer::rope_line_str(a, r)
8609        } else {
8610            String::new()
8611        };
8612        let lb = if r < b.len_lines() {
8613            hjkl_buffer::rope_line_str(b, r)
8614        } else {
8615            String::new()
8616        };
8617        if la != lb {
8618            let col = la
8619                .chars()
8620                .zip(lb.chars())
8621                .take_while(|(x, y)| x == y)
8622                .count();
8623            return Some((r, col));
8624        }
8625    }
8626    None
8627}
8628
8629// ─── Dot repeat ────────────────────────────────────────────────────────────
8630
8631/// Replay-side helper: insert `text` at the cursor through the
8632/// edit funnel, then leave insert mode (the original change ended
8633/// with Esc, so the dot-repeat must end the same way — including
8634/// the cursor step-back vim does on Esc-from-insert).
8635fn replay_insert_and_finish<H: crate::types::Host>(
8636    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8637    text: &str,
8638) {
8639    use hjkl_buffer::{Edit, Position};
8640    let cursor = ed.cursor();
8641    ed.mutate_edit(Edit::InsertStr {
8642        at: Position::new(cursor.0, cursor.1),
8643        text: text.to_string(),
8644    });
8645    if ed.vim.insert_session.take().is_some() {
8646        if ed.cursor().1 > 0 {
8647            crate::motions::move_left(&mut ed.buffer, 1);
8648            ed.push_buffer_cursor_to_textarea();
8649        }
8650        ed.vim.mode = Mode::Normal;
8651    }
8652}
8653
8654pub(crate) fn replay_last_change<H: crate::types::Host>(
8655    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8656    outer_count: usize,
8657) {
8658    let Some(change) = ed.vim.last_change.clone() else {
8659        return;
8660    };
8661    ed.vim.replaying = true;
8662    let scale = if outer_count > 0 { outer_count } else { 1 };
8663    match change {
8664        LastChange::OpMotion {
8665            op,
8666            motion,
8667            count,
8668            inserted,
8669        } => {
8670            let total = count.max(1) * scale;
8671            apply_op_with_motion(ed, op, &motion, total);
8672            if let Some(text) = inserted {
8673                replay_insert_and_finish(ed, &text);
8674            }
8675        }
8676        LastChange::OpTextObj {
8677            op,
8678            obj,
8679            inner,
8680            inserted,
8681        } => {
8682            // Dot-repeat replays the text object at count 1 (the original
8683            // count is not retained in `LastChange::OpTextObj`).
8684            apply_op_with_text_object(ed, op, obj, inner, 1);
8685            if let Some(text) = inserted {
8686                replay_insert_and_finish(ed, &text);
8687            }
8688        }
8689        LastChange::LineOp {
8690            op,
8691            count,
8692            inserted,
8693        } => {
8694            let total = count.max(1) * scale;
8695            execute_line_op(ed, op, total);
8696            if let Some(text) = inserted {
8697                replay_insert_and_finish(ed, &text);
8698            }
8699        }
8700        LastChange::CharDel { forward, count } => {
8701            do_char_delete(ed, forward, count * scale);
8702        }
8703        LastChange::ReplaceChar { ch, count } => {
8704            replace_char(ed, ch, count * scale);
8705        }
8706        LastChange::ToggleCase { count } => {
8707            for _ in 0..count * scale {
8708                ed.push_undo();
8709                toggle_case_at_cursor(ed);
8710            }
8711        }
8712        LastChange::JoinLine { count } => {
8713            for _ in 0..count * scale {
8714                ed.push_undo();
8715                join_line(ed);
8716            }
8717        }
8718        LastChange::Paste {
8719            before,
8720            count,
8721            cursor_after,
8722            reindent,
8723        } => {
8724            do_paste(ed, before, count * scale, cursor_after, reindent);
8725        }
8726        LastChange::GnOp {
8727            op,
8728            forward,
8729            inserted,
8730        } => {
8731            gn_operate(ed, Some(op), forward, 1);
8732            if let Some(text) = inserted {
8733                replay_insert_and_finish(ed, &text);
8734            }
8735        }
8736        LastChange::ReplaceMode { text } => {
8737            use hjkl_buffer::{Edit, MotionKind, Position};
8738            ed.push_undo();
8739            for ch in text.chars() {
8740                let cursor = buf_cursor_pos(&ed.buffer);
8741                let line_chars = buf_line_chars(&ed.buffer, cursor.row);
8742                if cursor.col < line_chars {
8743                    // Overtype the char under the cursor.
8744                    ed.mutate_edit(Edit::DeleteRange {
8745                        start: cursor,
8746                        end: Position::new(cursor.row, cursor.col + 1),
8747                        kind: MotionKind::Char,
8748                    });
8749                }
8750                ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
8751                buf_set_cursor_rc(&mut ed.buffer, cursor.row, cursor.col + 1);
8752            }
8753            // Esc step-back onto the last overtyped char.
8754            if ed.cursor().1 > 0 {
8755                crate::motions::move_left(&mut ed.buffer, 1);
8756            }
8757            ed.push_buffer_cursor_to_textarea();
8758        }
8759        LastChange::DeleteToEol { inserted } => {
8760            use hjkl_buffer::{Edit, Position};
8761            ed.push_undo();
8762            delete_to_eol(ed);
8763            if let Some(text) = inserted {
8764                let cursor = ed.cursor();
8765                ed.mutate_edit(Edit::InsertStr {
8766                    at: Position::new(cursor.0, cursor.1),
8767                    text,
8768                });
8769            }
8770        }
8771        LastChange::OpenLine { above, inserted } => {
8772            use hjkl_buffer::{Edit, Position};
8773            ed.push_undo();
8774            ed.sync_buffer_content_from_textarea();
8775            let row = buf_cursor_pos(&ed.buffer).row;
8776            if above {
8777                ed.mutate_edit(Edit::InsertStr {
8778                    at: Position::new(row, 0),
8779                    text: "\n".to_string(),
8780                });
8781                let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
8782                crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
8783            } else {
8784                let line_chars = buf_line_chars(&ed.buffer, row);
8785                ed.mutate_edit(Edit::InsertStr {
8786                    at: Position::new(row, line_chars),
8787                    text: "\n".to_string(),
8788                });
8789            }
8790            ed.push_buffer_cursor_to_textarea();
8791            let cursor = ed.cursor();
8792            ed.mutate_edit(Edit::InsertStr {
8793                at: Position::new(cursor.0, cursor.1),
8794                text: inserted,
8795            });
8796        }
8797        LastChange::InsertAt {
8798            entry,
8799            inserted,
8800            count,
8801        } => {
8802            use hjkl_buffer::{Edit, Position};
8803            ed.push_undo();
8804            match entry {
8805                InsertEntry::I => {}
8806                InsertEntry::ShiftI => move_first_non_whitespace(ed),
8807                InsertEntry::A => {
8808                    crate::motions::move_right_to_end(&mut ed.buffer, 1);
8809                    ed.push_buffer_cursor_to_textarea();
8810                }
8811                InsertEntry::ShiftA => {
8812                    crate::motions::move_line_end(&mut ed.buffer);
8813                    crate::motions::move_right_to_end(&mut ed.buffer, 1);
8814                    ed.push_buffer_cursor_to_textarea();
8815                }
8816            }
8817            for _ in 0..count.max(1) {
8818                let cursor = ed.cursor();
8819                ed.mutate_edit(Edit::InsertStr {
8820                    at: Position::new(cursor.0, cursor.1),
8821                    text: inserted.clone(),
8822                });
8823            }
8824        }
8825    }
8826    ed.vim.replaying = false;
8827}
8828
8829// ─── Extracting inserted text for replay ───────────────────────────────────
8830
8831/// The substring of `after` that differs from `before` (first-diff to
8832/// last-diff). Unlike [`extract_inserted`] this works for equal-length or
8833/// shorter results, so it captures `R` overstrike text for dot-repeat.
8834fn changed_run(before: &str, after: &str) -> String {
8835    let a: Vec<char> = before.chars().collect();
8836    let b: Vec<char> = after.chars().collect();
8837    let prefix = a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count();
8838    let max_suffix = a.len().min(b.len()) - prefix;
8839    let suffix = a
8840        .iter()
8841        .rev()
8842        .zip(b.iter().rev())
8843        .take(max_suffix)
8844        .take_while(|(x, y)| x == y)
8845        .count();
8846    b[prefix..b.len() - suffix].iter().collect()
8847}
8848
8849fn extract_inserted(before: &str, after: &str) -> String {
8850    let before_chars: Vec<char> = before.chars().collect();
8851    let after_chars: Vec<char> = after.chars().collect();
8852    if after_chars.len() <= before_chars.len() {
8853        return String::new();
8854    }
8855    let prefix = before_chars
8856        .iter()
8857        .zip(after_chars.iter())
8858        .take_while(|(a, b)| a == b)
8859        .count();
8860    let max_suffix = before_chars.len() - prefix;
8861    let suffix = before_chars
8862        .iter()
8863        .rev()
8864        .zip(after_chars.iter().rev())
8865        .take(max_suffix)
8866        .take_while(|(a, b)| a == b)
8867        .count();
8868    after_chars[prefix..after_chars.len() - suffix]
8869        .iter()
8870        .collect()
8871}
8872
8873// ─── Tests ────────────────────────────────────────────────────────────────
8874
8875#[cfg(test)]
8876mod comment_continuation_tests {
8877    use super::*;
8878    use crate::{DefaultHost, Editor, Options};
8879    use hjkl_buffer::Buffer;
8880
8881    fn make_editor_with_lang(lang: &str, content: &str) -> Editor<Buffer, DefaultHost> {
8882        let buf = Buffer::from_str(content);
8883        let host = DefaultHost::new();
8884        let opts = Options {
8885            filetype: lang.to_string(),
8886            formatoptions: "ro".to_string(),
8887            ..Options::default()
8888        };
8889        Editor::new(buf, host, opts)
8890    }
8891
8892    #[test]
8893    fn detect_rust_doc_comment() {
8894        let result = detect_comment_on_line("rust", "/// foo bar");
8895        assert!(result.is_some());
8896        let (indent, prefix) = result.unwrap();
8897        assert_eq!(indent, "");
8898        assert_eq!(prefix, "/// ");
8899    }
8900
8901    #[test]
8902    fn detect_rust_inner_doc_comment() {
8903        let result = detect_comment_on_line("rust", "//! crate docs");
8904        assert!(result.is_some());
8905        let (_, prefix) = result.unwrap();
8906        assert_eq!(prefix, "//! ");
8907    }
8908
8909    #[test]
8910    fn detect_rust_plain_comment() {
8911        let result = detect_comment_on_line("rust", "// normal comment");
8912        assert!(result.is_some());
8913        let (_, prefix) = result.unwrap();
8914        assert_eq!(prefix, "// ");
8915    }
8916
8917    #[test]
8918    fn detect_indented_comment() {
8919        let result = detect_comment_on_line("rust", "    // indented");
8920        assert!(result.is_some());
8921        let (indent, prefix) = result.unwrap();
8922        assert_eq!(indent, "    ");
8923        assert_eq!(prefix, "// ");
8924    }
8925
8926    #[test]
8927    fn detect_python_hash() {
8928        let result = detect_comment_on_line("python", "# comment");
8929        assert!(result.is_some());
8930        let (_, prefix) = result.unwrap();
8931        assert_eq!(prefix, "# ");
8932    }
8933
8934    #[test]
8935    fn detect_lua_double_dash() {
8936        let result = detect_comment_on_line("lua", "-- a lua comment");
8937        assert!(result.is_some());
8938        let (_, prefix) = result.unwrap();
8939        assert_eq!(prefix, "-- ");
8940    }
8941
8942    #[test]
8943    fn detect_non_comment_is_none() {
8944        assert!(detect_comment_on_line("rust", "let x = 1;").is_none());
8945        assert!(detect_comment_on_line("python", "x = 1").is_none());
8946    }
8947
8948    #[test]
8949    fn detect_bare_double_slash_still_matches() {
8950        // A line that is exactly `//` with nothing after.
8951        assert!(detect_comment_on_line("rust", "//").is_some());
8952    }
8953
8954    #[test]
8955    fn rust_doc_before_plain() {
8956        // `///` must match before `//`.
8957        let result = detect_comment_on_line("rust", "/// outer doc");
8958        let (_, prefix) = result.unwrap();
8959        assert_eq!(prefix, "/// ", "/// must match before //");
8960    }
8961
8962    #[test]
8963    fn continue_comment_returns_prefix_for_comment_row() {
8964        let ed = make_editor_with_lang("rust", "/// hello\n");
8965        let cont = continue_comment(&ed.buffer, &ed.settings, 0);
8966        assert_eq!(cont, Some("/// ".to_string()));
8967    }
8968
8969    #[test]
8970    fn continue_comment_returns_none_for_non_comment() {
8971        let ed = make_editor_with_lang("rust", "let x = 1;\n");
8972        let cont = continue_comment(&ed.buffer, &ed.settings, 0);
8973        assert!(cont.is_none());
8974    }
8975
8976    #[test]
8977    fn continue_comment_returns_none_when_filetype_empty() {
8978        let buf = Buffer::from_str("// hello\n");
8979        let host = DefaultHost::new();
8980        // filetype defaults to "" in Options::default().
8981        let ed = Editor::new(buf, host, Options::default());
8982        let cont = continue_comment(&ed.buffer, &ed.settings, 0);
8983        assert!(cont.is_none());
8984    }
8985}
8986
8987#[cfg(test)]
8988mod comment_toggle_tests {
8989    use super::*;
8990    use crate::{DefaultHost, Editor, Options};
8991    use hjkl_buffer::Buffer;
8992
8993    fn make_rust_editor(content: &str) -> Editor<Buffer, DefaultHost> {
8994        let buf = Buffer::from_str(content);
8995        let host = DefaultHost::new();
8996        let opts = Options {
8997            filetype: "rust".to_string(),
8998            ..Options::default()
8999        };
9000        Editor::new(buf, host, opts)
9001    }
9002
9003    fn line(ed: &Editor<Buffer, DefaultHost>, row: usize) -> String {
9004        buf_line(&ed.buffer, row).unwrap_or_default()
9005    }
9006
9007    // ── gcc: toggle comment on current line ──────────────────────────────────
9008
9009    #[test]
9010    fn gcc_comments_rust_line() {
9011        let mut ed = make_rust_editor("let x = 1;");
9012        ed.toggle_comment_range(0, 0);
9013        assert_eq!(line(&ed, 0), "// let x = 1;");
9014    }
9015
9016    #[test]
9017    fn gcc_uncomments_rust_line() {
9018        let mut ed = make_rust_editor("// let x = 1;");
9019        ed.toggle_comment_range(0, 0);
9020        assert_eq!(line(&ed, 0), "let x = 1;");
9021    }
9022
9023    #[test]
9024    fn gcc_indent_preserving() {
9025        // Marker inserted after leading whitespace, not at column 0.
9026        let mut ed = make_rust_editor("    let x = 1;");
9027        ed.toggle_comment_range(0, 0);
9028        assert_eq!(line(&ed, 0), "    // let x = 1;");
9029    }
9030
9031    #[test]
9032    fn gcc_indent_preserving_uncomment() {
9033        let mut ed = make_rust_editor("    // let x = 1;");
9034        ed.toggle_comment_range(0, 0);
9035        assert_eq!(line(&ed, 0), "    let x = 1;");
9036    }
9037
9038    // ── Multi-line toggle ────────────────────────────────────────────────────
9039
9040    #[test]
9041    fn toggle_multi_line_all_uncommented() {
9042        let content = "let a = 1;\nlet b = 2;\nlet c = 3;";
9043        let mut ed = make_rust_editor(content);
9044        ed.toggle_comment_range(0, 2);
9045        assert_eq!(line(&ed, 0), "// let a = 1;");
9046        assert_eq!(line(&ed, 1), "// let b = 2;");
9047        assert_eq!(line(&ed, 2), "// let c = 3;");
9048    }
9049
9050    #[test]
9051    fn toggle_multi_line_all_commented() {
9052        let content = "// let a = 1;\n// let b = 2;\n// let c = 3;";
9053        let mut ed = make_rust_editor(content);
9054        ed.toggle_comment_range(0, 2);
9055        assert_eq!(line(&ed, 0), "let a = 1;");
9056        assert_eq!(line(&ed, 1), "let b = 2;");
9057        assert_eq!(line(&ed, 2), "let c = 3;");
9058    }
9059
9060    // ── Mixed state → all gets commented (vim-commentary parity) ────────────
9061
9062    #[test]
9063    fn toggle_mixed_state_comments_all() {
9064        // 3 uncommented + 2 commented → all 5 get commented.
9065        let content = "let a = 1;\n// let b = 2;\nlet c = 3;\n// let d = 4;\nlet e = 5;";
9066        let mut ed = make_rust_editor(content);
9067        ed.toggle_comment_range(0, 4);
9068        for r in 0..5 {
9069            assert!(
9070                line(&ed, r).trim_start().starts_with("//"),
9071                "row {r} not commented: {:?}",
9072                line(&ed, r)
9073            );
9074        }
9075    }
9076
9077    // ── Blank lines skipped ──────────────────────────────────────────────────
9078
9079    #[test]
9080    fn blank_lines_not_commented() {
9081        let content = "let a = 1;\n\nlet b = 2;";
9082        let mut ed = make_rust_editor(content);
9083        ed.toggle_comment_range(0, 2);
9084        assert_eq!(line(&ed, 0), "// let a = 1;");
9085        assert_eq!(line(&ed, 1), ""); // blank — untouched
9086        assert_eq!(line(&ed, 2), "// let b = 2;");
9087    }
9088
9089    // ── Python hash comments ─────────────────────────────────────────────────
9090
9091    #[test]
9092    fn python_comment_toggle() {
9093        let buf = Buffer::from_str("x = 1\ny = 2");
9094        let host = DefaultHost::new();
9095        let opts = Options {
9096            filetype: "python".to_string(),
9097            ..Options::default()
9098        };
9099        let mut ed = Editor::new(buf, host, opts);
9100        ed.toggle_comment_range(0, 1);
9101        assert_eq!(line(&ed, 0), "# x = 1");
9102        assert_eq!(line(&ed, 1), "# y = 2");
9103        // Toggle back.
9104        ed.toggle_comment_range(0, 1);
9105        assert_eq!(line(&ed, 0), "x = 1");
9106        assert_eq!(line(&ed, 1), "y = 2");
9107    }
9108
9109    // ── commentstring override ───────────────────────────────────────────────
9110
9111    #[test]
9112    fn commentstring_override_via_setting() {
9113        let buf = Buffer::from_str("hello world");
9114        let host = DefaultHost::new();
9115        let opts = Options {
9116            filetype: "rust".to_string(),
9117            ..Options::default()
9118        };
9119        let mut ed = Editor::new(buf, host, opts);
9120        // Override with a custom marker.
9121        ed.settings_mut().commentstring = "# %s".to_string();
9122        ed.toggle_comment_range(0, 0);
9123        assert_eq!(line(&ed, 0), "# hello world");
9124    }
9125
9126    // ── Unknown language → no-op ─────────────────────────────────────────────
9127
9128    #[test]
9129    fn unknown_lang_no_op() {
9130        let buf = Buffer::from_str("hello");
9131        let host = DefaultHost::new();
9132        let opts = Options::default(); // filetype = ""
9133        let mut ed = Editor::new(buf, host, opts);
9134        ed.toggle_comment_range(0, 0);
9135        // Should be unchanged — no comment string for "".
9136        assert_eq!(line(&ed, 0), "hello");
9137    }
9138}
9139
9140// ─── g& tests ─────────────────────────────────────────────────────────────
9141
9142#[cfg(test)]
9143mod g_ampersand_tests {
9144    use super::*;
9145    use crate::{DefaultHost, Editor, Options};
9146    use hjkl_buffer::{Buffer, rope_line_str};
9147
9148    fn make_editor(content: &str) -> Editor<Buffer, DefaultHost> {
9149        let buf = Buffer::from_str(content);
9150        let host = DefaultHost::new();
9151        Editor::new(buf, host, Options::default())
9152    }
9153
9154    fn buf_line(ed: &Editor<Buffer, DefaultHost>, row: usize) -> String {
9155        let rope = ed.buffer().rope();
9156        rope_line_str(&rope, row).trim_end_matches('\n').to_string()
9157    }
9158
9159    /// `g&` repeats last `:s/foo/bar/` over every line (no /g flag → first
9160    /// match per line only).
9161    #[test]
9162    fn g_ampersand_repeats_last_substitute_on_whole_buffer() {
9163        let mut ed = make_editor("foo\nfoo bar foo\nbaz");
9164        // Simulate a prior `:s/foo/bar/` by setting last_substitute directly.
9165        let cmd = crate::substitute::parse_substitute("/foo/bar/").unwrap();
9166        ed.set_last_substitute(cmd);
9167        // Cursor on line 0 (to confirm g& operates on ALL lines, not just current).
9168        apply_after_g(&mut ed, '&', 1);
9169        assert_eq!(buf_line(&ed, 0), "bar");
9170        // No /g flag — only first match per line.
9171        assert_eq!(buf_line(&ed, 1), "bar bar foo");
9172        assert_eq!(buf_line(&ed, 2), "baz");
9173    }
9174
9175    /// `g&` with /g flag replaces all matches per line.
9176    #[test]
9177    fn g_ampersand_with_g_flag_replaces_all_per_line() {
9178        let mut ed = make_editor("foo foo\nfoo");
9179        let cmd = crate::substitute::parse_substitute("/foo/bar/g").unwrap();
9180        ed.set_last_substitute(cmd);
9181        apply_after_g(&mut ed, '&', 1);
9182        assert_eq!(buf_line(&ed, 0), "bar bar");
9183        assert_eq!(buf_line(&ed, 1), "bar");
9184    }
9185
9186    /// `g&` with no prior substitute is a no-op.
9187    #[test]
9188    fn g_ampersand_noop_when_no_prior_substitute() {
9189        let mut ed = make_editor("foo\nbar");
9190        // No last_substitute set — must not panic, must not change buffer.
9191        apply_after_g(&mut ed, '&', 1);
9192        assert_eq!(buf_line(&ed, 0), "foo");
9193        assert_eq!(buf_line(&ed, 1), "bar");
9194    }
9195}
9196
9197// ─── Sneak motion tests ───────────────────────────────────────────────────
9198
9199#[cfg(test)]
9200mod sneak_tests {
9201    use super::*;
9202    use crate::{DefaultHost, Editor, Options};
9203    use hjkl_buffer::Buffer;
9204
9205    fn make_editor(content: &str) -> Editor<Buffer, DefaultHost> {
9206        let buf = Buffer::from_str(content);
9207        let host = DefaultHost::new();
9208        Editor::new(buf, host, Options::default())
9209    }
9210
9211    /// `s ba` from [0,0] on "foo bar baz qux\n" → cursor at [0,4] (start of "ba" in "bar").
9212    #[test]
9213    fn sneak_forward_jumps_to_two_char_digraph() {
9214        let mut ed = make_editor("foo bar baz qux\n");
9215        ed.jump_cursor(0, 0);
9216        ed.sneak('b', 'a', true, 1);
9217        assert_eq!(ed.cursor(), (0, 4), "cursor should land on 'ba' in 'bar'");
9218    }
9219
9220    /// `S ba` from [0,12] on "foo bar baz qux\n" → cursor at [0,8] ("ba" in "baz").
9221    #[test]
9222    fn sneak_backward_jumps_to_prior_match() {
9223        let mut ed = make_editor("foo bar baz qux\n");
9224        ed.jump_cursor(0, 12);
9225        ed.sneak('b', 'a', false, 1);
9226        assert_eq!(
9227            ed.cursor(),
9228            (0, 8),
9229            "backward sneak should find 'ba' in 'baz'"
9230        );
9231    }
9232
9233    /// After sneak forward to "bar", `;` (sneak-repeat) jumps to next "ba" ("baz").
9234    #[test]
9235    fn sneak_repeat_semicolon_next_match() {
9236        let mut ed = make_editor("foo bar baz qux\n");
9237        ed.jump_cursor(0, 0);
9238        // First sneak: lands at [0,4]
9239        ed.sneak('b', 'a', true, 1);
9240        assert_eq!(ed.cursor(), (0, 4));
9241        // Repeat via execute_motion FindRepeat (which routes through sneak if last was sneak)
9242        execute_motion(&mut ed, Motion::FindRepeat { reverse: false }, 1);
9243        assert_eq!(ed.cursor(), (0, 8), "semicolon should jump to next 'ba'");
9244    }
9245
9246    /// After sneak forward from [0,0] to [0,4], `,` (reverse) — no prior "ba" → stays.
9247    #[test]
9248    fn sneak_repeat_comma_prev_match() {
9249        let mut ed = make_editor("foo bar baz qux\n");
9250        ed.jump_cursor(0, 0);
9251        ed.sneak('b', 'a', true, 1);
9252        assert_eq!(ed.cursor(), (0, 4));
9253        // Reverse repeat — no "ba" before col 4, so cursor must not move.
9254        let pre = ed.cursor();
9255        execute_motion(&mut ed, Motion::FindRepeat { reverse: true }, 1);
9256        assert_eq!(
9257            ed.cursor(),
9258            pre,
9259            "comma with no prior match should leave cursor unchanged"
9260        );
9261    }
9262
9263    /// `S ba` from [0,12] jumps backward.
9264    #[test]
9265    fn sneak_s_searches_backward() {
9266        let mut ed = make_editor("foo bar baz qux\n");
9267        ed.jump_cursor(0, 12);
9268        ed.sneak('b', 'a', false, 1);
9269        assert_eq!(ed.cursor(), (0, 8));
9270    }
9271
9272    /// `2s ba` from [0,0] jumps to 2nd "ba" occurrence.
9273    #[test]
9274    fn sneak_with_count_jumps_to_nth() {
9275        let mut ed = make_editor("foo bar baz qux\n");
9276        ed.jump_cursor(0, 0);
9277        ed.sneak('b', 'a', true, 2);
9278        assert_eq!(ed.cursor(), (0, 8), "count=2 should jump to 2nd 'ba'");
9279    }
9280
9281    /// `s xx` with no match — cursor stays put.
9282    #[test]
9283    fn sneak_no_match_cursor_stays() {
9284        let mut ed = make_editor("foo bar baz qux\n");
9285        ed.jump_cursor(0, 0);
9286        let pre = ed.cursor();
9287        ed.sneak('x', 'x', true, 1);
9288        assert_eq!(ed.cursor(), pre, "no match should leave cursor unchanged");
9289    }
9290
9291    /// `dsab` on "hello ab world\n" from [0,0] → deletes up to 'ab', leaving "ab world\n".
9292    #[test]
9293    fn operator_pending_dsab_deletes_to_digraph() {
9294        let mut ed = make_editor("hello ab world\n");
9295        ed.jump_cursor(0, 0);
9296        ed.apply_op_sneak(Operator::Delete, 'a', 'b', true, 1);
9297        // Buffer content after exclusive delete from [0,0] to [0,6] (start of "ab").
9298        let content = ed.content();
9299        assert!(
9300            content.starts_with("ab world"),
9301            "dsab should delete 'hello ' leaving 'ab world'; got: {content:?}"
9302        );
9303    }
9304
9305    /// Cross-line sneak: "foo\nbar baz\n", cursor [0,0], `s ba` → [1,0].
9306    #[test]
9307    fn sneak_cross_line_match() {
9308        let mut ed = make_editor("foo\nbar baz\n");
9309        ed.jump_cursor(0, 0);
9310        ed.sneak('b', 'a', true, 1);
9311        assert_eq!(ed.cursor(), (1, 0), "sneak should cross line boundary");
9312    }
9313
9314    /// `last_sneak` is updated after `sneak()` so `;`/`,` can repeat.
9315    #[test]
9316    fn sneak_updates_last_sneak_state() {
9317        let mut ed = make_editor("foo bar baz\n");
9318        ed.jump_cursor(0, 0);
9319        ed.sneak('b', 'a', true, 1);
9320        let ls = ed.last_sneak();
9321        assert_eq!(
9322            ls,
9323            Some((('b', 'a'), true)),
9324            "last_sneak should record the digraph and direction"
9325        );
9326    }
9327}
9328
9329// ─── [count]>> / [count]<< line-operator count tests ──────────────────────
9330//
9331// vim semantics (captured from `nvim --headless`, mirrored by the
9332// `tier2_indent_count` oracle corpus):
9333//   - `[count]op` operates on `count` lines from the cursor, clamped to the
9334//     buffer end.
9335//   - The implied `count_` motion moves `count - 1` lines down; on the last
9336//     line it can't move, so `[count>=2]>>` / `<<` is a complete no-op (E16).
9337#[cfg(test)]
9338mod indent_count_tests {
9339    use super::*;
9340    use crate::{DefaultHost, Editor, Options};
9341    use hjkl_buffer::Buffer;
9342
9343    fn make_editor(content: &str) -> Editor<Buffer, DefaultHost> {
9344        let buf = Buffer::from_str(content);
9345        let mut ed = Editor::new(buf, DefaultHost::new(), Options::default());
9346        ed.settings_mut().expandtab = true;
9347        ed.settings_mut().shiftwidth = 4;
9348        ed
9349    }
9350
9351    fn content(ed: &Editor<Buffer, DefaultHost>) -> String {
9352        (*ed.buffer().content_joined()).clone()
9353    }
9354
9355    #[test]
9356    fn count_indent_operates_on_n_lines() {
9357        let mut ed = make_editor("a\nb\nc\nd\ne\nf\n");
9358        ed.jump_cursor(0, 0);
9359        execute_line_op(&mut ed, Operator::Indent, 3);
9360        assert_eq!(content(&ed), "    a\n    b\n    c\nd\ne\nf\n");
9361    }
9362
9363    // #263: `>>` under `noexpandtab` must insert a hard tab, not spaces.
9364    #[test]
9365    fn indent_noexpandtab_inserts_tab() {
9366        let mut ed = make_editor("hello\n");
9367        ed.settings_mut().expandtab = false;
9368        ed.settings_mut().shiftwidth = 4;
9369        ed.settings_mut().tabstop = 4;
9370        ed.jump_cursor(0, 0);
9371        execute_line_op(&mut ed, Operator::Indent, 1);
9372        assert_eq!(content(&ed), "\thello\n");
9373    }
9374
9375    // #263: a sub-tabstop shiftwidth under `noexpandtab` pads with spaces for
9376    // the remainder (shiftwidth=2 < tabstop=4 → two spaces, no tab).
9377    #[test]
9378    fn indent_noexpandtab_subtab_remainder_is_spaces() {
9379        let mut ed = make_editor("hello\n");
9380        ed.settings_mut().expandtab = false;
9381        ed.settings_mut().shiftwidth = 2;
9382        ed.settings_mut().tabstop = 4;
9383        ed.jump_cursor(0, 0);
9384        execute_line_op(&mut ed, Operator::Indent, 1);
9385        assert_eq!(content(&ed), "  hello\n");
9386    }
9387
9388    #[test]
9389    fn count_indent_clamps_to_buffer_end() {
9390        let mut ed = make_editor("a\nb\nc\nd\ne\nf\n");
9391        ed.jump_cursor(0, 0);
9392        execute_line_op(&mut ed, Operator::Indent, 10);
9393        assert_eq!(content(&ed), "    a\n    b\n    c\n    d\n    e\n    f\n");
9394    }
9395
9396    #[test]
9397    fn count_outdent_clamps_to_buffer_end() {
9398        let mut ed = make_editor("    a\n    b\n    c\n");
9399        ed.jump_cursor(0, 0);
9400        execute_line_op(&mut ed, Operator::Outdent, 10);
9401        assert_eq!(content(&ed), "a\nb\nc\n");
9402    }
9403
9404    #[test]
9405    fn count_indent_on_last_line_is_noop() {
9406        let mut ed = make_editor("a\nb\nc\n");
9407        ed.jump_cursor(2, 0); // last content line
9408        execute_line_op(&mut ed, Operator::Indent, 5);
9409        assert_eq!(
9410            content(&ed),
9411            "a\nb\nc\n",
9412            "5>> on last line must abort (E16)"
9413        );
9414    }
9415
9416    #[test]
9417    fn count_indent_on_single_line_is_noop() {
9418        let mut ed = make_editor("x\n");
9419        ed.jump_cursor(0, 0);
9420        execute_line_op(&mut ed, Operator::Indent, 5);
9421        assert_eq!(content(&ed), "x\n", "5>> on the only line must abort (E16)");
9422    }
9423
9424    #[test]
9425    fn count_outdent_on_last_line_is_noop() {
9426        let mut ed = make_editor("    a\n    b\n    c\n");
9427        ed.jump_cursor(2, 0);
9428        execute_line_op(&mut ed, Operator::Outdent, 5);
9429        assert_eq!(content(&ed), "    a\n    b\n    c\n");
9430    }
9431
9432    #[test]
9433    fn single_indent_on_last_line_still_works() {
9434        // count == 1 needs no motion, so `>>` on the last line indents it.
9435        let mut ed = make_editor("a\nb\nc\n");
9436        ed.jump_cursor(2, 0);
9437        execute_line_op(&mut ed, Operator::Indent, 1);
9438        assert_eq!(content(&ed), "a\nb\n    c\n");
9439    }
9440}
9441
9442// ── try_abbrev_expand unit tests ─────────────────────────────────────────────
9443
9444#[cfg(test)]
9445mod abbrev_tests {
9446    use super::{Abbrev, AbbrevKind, AbbrevTrigger, abbrev_kind, try_abbrev_expand};
9447    use AbbrevKind::{End, Full, NonKw};
9448
9449    const ISK: &str = "@,48-57,_,192-255"; // default iskeyword
9450
9451    fn make_abbrev(lhs: &str, rhs: &str) -> Abbrev {
9452        Abbrev {
9453            lhs: lhs.to_string(),
9454            rhs: rhs.to_string(),
9455            insert: true,
9456            cmdline: false,
9457            noremap: false,
9458        }
9459    }
9460
9461    fn expand(
9462        abbrevs: &[Abbrev],
9463        before: &str,
9464        mincol: usize,
9465        trig: AbbrevTrigger,
9466    ) -> Option<(usize, String)> {
9467        try_abbrev_expand(abbrevs, before, mincol, trig, ISK)
9468    }
9469
9470    // ── abbrev_type classification ────────────────────────────────────────────
9471
9472    #[test]
9473    fn fullid_all_keyword_chars() {
9474        assert_eq!(abbrev_kind("teh", ISK), Full);
9475        assert_eq!(abbrev_kind("abc123", ISK), Full);
9476        assert_eq!(abbrev_kind("_foo", ISK), Full);
9477    }
9478
9479    #[test]
9480    fn endid_ends_with_kw_has_nonkw() {
9481        assert_eq!(abbrev_kind("#i", ISK), End);
9482        assert_eq!(abbrev_kind("#include", ISK), End);
9483    }
9484
9485    #[test]
9486    fn nonid_ends_with_nonkw() {
9487        assert_eq!(abbrev_kind(";;", ISK), NonKw);
9488        assert_eq!(abbrev_kind("->", ISK), NonKw);
9489    }
9490
9491    // ── full-id expansion ─────────────────────────────────────────────────────
9492
9493    #[test]
9494    fn fullid_expands_on_space_trigger() {
9495        let abbrevs = [make_abbrev("teh", "the")];
9496        let r = expand(&abbrevs, "teh", 0, AbbrevTrigger::NonKeyword(' '));
9497        assert_eq!(r, Some((3, "the".to_string())));
9498    }
9499
9500    #[test]
9501    fn fullid_expands_on_esc_trigger() {
9502        let abbrevs = [make_abbrev("teh", "the")];
9503        let r = expand(&abbrevs, "teh", 0, AbbrevTrigger::Esc);
9504        assert_eq!(r, Some((3, "the".to_string())));
9505    }
9506
9507    #[test]
9508    fn fullid_expands_on_cr_trigger() {
9509        let abbrevs = [make_abbrev("teh", "the")];
9510        let r = expand(&abbrevs, "teh", 0, AbbrevTrigger::Cr);
9511        assert_eq!(r, Some((3, "the".to_string())));
9512    }
9513
9514    #[test]
9515    fn fullid_expands_on_ctrl_bracket() {
9516        let abbrevs = [make_abbrev("teh", "the")];
9517        let r = expand(&abbrevs, "teh", 0, AbbrevTrigger::CtrlBracket);
9518        assert_eq!(r, Some((3, "the".to_string())));
9519    }
9520
9521    #[test]
9522    fn fullid_does_not_expand_on_keyword_trigger() {
9523        // Typing a keyword char after "teh" would extend the word — no expand.
9524        let abbrevs = [make_abbrev("teh", "the")];
9525        let r = expand(&abbrevs, "teh", 0, AbbrevTrigger::NonKeyword('a'));
9526        // 'a' is keyword — should not trigger
9527        assert_eq!(r, None);
9528    }
9529
9530    #[test]
9531    fn fullid_no_expand_when_lhs_not_at_end() {
9532        let abbrevs = [make_abbrev("teh", "the")];
9533        // "ateh" — 'a' before is keyword, so skip.
9534        let r = expand(&abbrevs, "ateh", 0, AbbrevTrigger::NonKeyword(' '));
9535        assert_eq!(r, None);
9536    }
9537
9538    #[test]
9539    fn fullid_expands_after_nonkw_prefix() {
9540        let abbrevs = [make_abbrev("teh", "the")];
9541        // "!teh" — '!' before is non-keyword → expand.
9542        let r = expand(&abbrevs, "!teh", 0, AbbrevTrigger::NonKeyword(' '));
9543        assert_eq!(r, Some((3, "the".to_string())));
9544    }
9545
9546    #[test]
9547    fn fullid_single_char_no_expand_after_nonblank_nonkw() {
9548        let abbrevs = [make_abbrev("a", "b")];
9549        // "!a" — '!' is non-blank non-keyword before single-char lhs → no expand.
9550        let r = expand(&abbrevs, "!a", 0, AbbrevTrigger::NonKeyword(' '));
9551        assert_eq!(r, None);
9552    }
9553
9554    #[test]
9555    fn fullid_single_char_expands_after_space() {
9556        let abbrevs = [make_abbrev("a", "b")];
9557        // " a" — space before single-char lhs → expand.
9558        let r = expand(&abbrevs, " a", 0, AbbrevTrigger::NonKeyword(' '));
9559        assert_eq!(r, Some((1, "b".to_string())));
9560    }
9561
9562    // ── mincol: pre-existing text must not be consumed ────────────────────────
9563
9564    #[test]
9565    fn mincol_blocks_consuming_preexisting_text() {
9566        let abbrevs = [make_abbrev("teh", "the")];
9567        // "teh" is at cols 0..3, but insert started at col 3 → no match.
9568        let r = expand(&abbrevs, "teh", 3, AbbrevTrigger::NonKeyword(' '));
9569        assert_eq!(r, None);
9570    }
9571
9572    #[test]
9573    fn mincol_allows_match_starting_at_mincol() {
9574        let abbrevs = [make_abbrev("teh", "the")];
9575        // Existing text "!! " at 0..3, then user typed "teh" → mincol=3.
9576        // The char before the lhs is ' ' (non-keyword), so full-id expands.
9577        let r = expand(&abbrevs, "!! teh", 3, AbbrevTrigger::NonKeyword(' '));
9578        assert_eq!(r, Some((3, "the".to_string())));
9579    }
9580
9581    // ── end-id expansion ──────────────────────────────────────────────────────
9582
9583    #[test]
9584    fn endid_expands_on_space_trigger() {
9585        let abbrevs = [make_abbrev("#i", "#include")];
9586        let r = expand(&abbrevs, "#i", 0, AbbrevTrigger::NonKeyword(' '));
9587        assert_eq!(r, Some((2, "#include".to_string())));
9588    }
9589
9590    #[test]
9591    fn endid_expands_on_esc_trigger() {
9592        let abbrevs = [make_abbrev("#i", "#include")];
9593        let r = expand(&abbrevs, "#i", 0, AbbrevTrigger::Esc);
9594        assert_eq!(r, Some((2, "#include".to_string())));
9595    }
9596
9597    // ── non-id expansion ──────────────────────────────────────────────────────
9598
9599    #[test]
9600    fn nonid_expands_on_esc_trigger() {
9601        let abbrevs = [make_abbrev(";;", "std::endl;")];
9602        let r = expand(&abbrevs, ";;", 0, AbbrevTrigger::Esc);
9603        assert_eq!(r, Some((2, "std::endl;".to_string())));
9604    }
9605
9606    #[test]
9607    fn nonid_expands_on_cr_trigger() {
9608        let abbrevs = [make_abbrev(";;", "std::endl;")];
9609        let r = expand(&abbrevs, ";;", 0, AbbrevTrigger::Cr);
9610        assert_eq!(r, Some((2, "std::endl;".to_string())));
9611    }
9612
9613    #[test]
9614    fn nonid_does_not_expand_on_nonkw_trigger() {
9615        // non-id abbreviations must NOT expand on regular typed chars like space.
9616        let abbrevs = [make_abbrev(";;", "std::endl;")];
9617        let r = expand(&abbrevs, ";;", 0, AbbrevTrigger::NonKeyword(' '));
9618        assert_eq!(r, None);
9619    }
9620
9621    #[test]
9622    fn nonid_expands_on_ctrl_bracket() {
9623        let abbrevs = [make_abbrev(";;", "std::endl;")];
9624        let r = expand(&abbrevs, ";;", 0, AbbrevTrigger::CtrlBracket);
9625        assert_eq!(r, Some((2, "std::endl;".to_string())));
9626    }
9627
9628    // ── multiword rhs ─────────────────────────────────────────────────────────
9629
9630    #[test]
9631    fn multiword_rhs_expansion() {
9632        let abbrevs = [make_abbrev("hw", "hello world")];
9633        let r = expand(&abbrevs, "hw", 0, AbbrevTrigger::NonKeyword(' '));
9634        assert_eq!(r, Some((2, "hello world".to_string())));
9635    }
9636
9637    // ── empty / no match ─────────────────────────────────────────────────────
9638
9639    #[test]
9640    fn no_match_returns_none() {
9641        let abbrevs = [make_abbrev("teh", "the")];
9642        let r = expand(&abbrevs, "xyz", 0, AbbrevTrigger::NonKeyword(' '));
9643        assert_eq!(r, None);
9644    }
9645
9646    #[test]
9647    fn empty_abbrevs_returns_none() {
9648        let r = expand(&[], "teh", 0, AbbrevTrigger::NonKeyword(' '));
9649        assert_eq!(r, None);
9650    }
9651
9652    #[test]
9653    fn empty_before_text_returns_none() {
9654        let abbrevs = [make_abbrev("teh", "the")];
9655        let r = expand(&abbrevs, "", 0, AbbrevTrigger::NonKeyword(' '));
9656        assert_eq!(r, None);
9657    }
9658}
9659
9660// ─── scan_tag_opener / autoclose multibyte tests ──────────────────────────────
9661
9662#[cfg(test)]
9663mod scan_tag_opener_multibyte_tests {
9664    use crate::types::Options;
9665    use crate::{DefaultHost, Editor};
9666    use hjkl_buffer::Buffer;
9667
9668    fn html_editor(content: &str) -> Editor<Buffer, DefaultHost> {
9669        let buf = Buffer::from_str(content);
9670        let host = DefaultHost::new();
9671        let mut ed = Editor::new(buf, host, Options::default());
9672        ed.settings.filetype = "html".to_string();
9673        ed.settings.autoclose_tag = true;
9674        ed.settings.autopair = true;
9675        ed
9676    }
9677
9678    /// Typing `>` after a multibyte char must not panic.
9679    ///
9680    /// With "ñ" in the buffer (ñ = 2 UTF-8 bytes), the cursor is at char
9681    /// col 1 (one past ñ).  `insert_char('>')` calls `scan_tag_opener` with
9682    /// `col = cursor.col = 1`.  Before the fix, `&line[..1]` landed inside
9683    /// the 2-byte ñ sequence → panic "byte index 1 is not a char boundary".
9684    #[test]
9685    fn autoclose_gt_after_multibyte_no_panic() {
9686        let mut ed = html_editor("ñ");
9687        // Cursor starts at col 0 (on ñ). Enter insert at end-of-line.
9688        ed.enter_insert_i(1);
9689        // Move to end (col 1, after ñ).
9690        ed.jump_cursor(0, 1);
9691        // Insert '>' — must not panic.
9692        ed.insert_char('>');
9693        // The `>` should be in the buffer (no autoclose tag fires for bare ">").
9694        let rope = ed.buffer().rope();
9695        let line = hjkl_buffer::rope_line_str(&rope, 0);
9696        assert!(line.contains('>'), "inserted > must appear in buffer");
9697    }
9698
9699    /// Same repro via the direct tag-autoclose path.
9700    ///
9701    /// "ä<div" has a multibyte char at the start.  Positioning the cursor
9702    /// at char col 5 (after 'v') and inserting '>' exercises the
9703    /// scan_tag_opener branch.  Before the fix, `col = cursor.col = 5` and
9704    /// `&line[..5]` is byte index 5, which falls inside 'ä' (2 bytes at
9705    /// positions 0-1) — wait, 'ä'=2 bytes then '<','d','i','v' are 1 byte
9706    /// each, so byte 5 = 'v' (valid boundary).  Use a CJK char (3 bytes)
9707    /// to force a panic at a narrower position:
9708    ///
9709    /// "中<div>": 中 = 3 bytes; after '>', char col 5 → byte index 5.
9710    /// Bytes: 中=0,1,2  <=3  d=4  i=5  v=6  >=7.  Char index 4 = 'i', byte 4
9711    /// is safe. Need char 2 to map to byte 5 → that's inside '<'.
9712    ///
9713    /// Simplest panic case: "ñ>" (ñ=2 bytes, >=1 byte):
9714    /// char 0=ñ, char 1=>; cursor.col=1, &line[..1] = byte 1 = 0xb1 inside ñ → PANIC.
9715    #[test]
9716    fn autoclose_gt_direct_after_multibyte_no_panic() {
9717        // "ñ>" already in buffer — cursor at char col 1 (the '>').
9718        // We'll test by inserting '>' after 'ñ' from scratch.
9719        let mut ed = html_editor("ñ");
9720        ed.enter_insert_i(1);
9721        ed.jump_cursor(0, 1); // char col 1 = one past ñ
9722        // Insert '>' — before fix: scan_tag_opener("ñ>", 1) → &"ñ>"[..1] panics.
9723        ed.insert_char('>');
9724        let rope = ed.buffer().rope();
9725        let line = hjkl_buffer::rope_line_str(&rope, 0);
9726        assert!(
9727            line.contains('>'),
9728            "inserted > must appear in buffer, got: {line:?}"
9729        );
9730    }
9731}