Skip to main content

hjkl_engine/
vim.rs

1//! Vim-mode engine.
2//!
3//! Implements a command grammar of the form
4//!
5//! ```text
6//! Command := count? (operator count? (motion | text-object)
7//!                   | motion
8//!                   | insert-entry
9//!                   | misc)
10//! ```
11//!
12//! The parser is a small state machine driven by one `Input` at a time.
13//! Motions and text objects produce a [`Range`] (with inclusive/exclusive
14//! / linewise classification). A single [`Operator`] implementation
15//! applies a range — so `dw`, `d$`, `daw`, and visual `d` all go through
16//! the same code path.
17//!
18//! The most recent mutating command is stored in
19//! [`VimState::last_change`] so `.` can replay it.
20//!
21//! # Roadmap
22//!
23//! Tracked in the original plan at
24//! `~/.claude/plans/look-at-the-vim-curried-fern.md`. Phases still
25//! outstanding — each one can land as an isolated PR.
26//!
27//! ## P3 — Registers & marks
28//!
29//! - TODO: `RegisterBank` indexed by char:
30//!     - unnamed `""`, last-yank `"0`, small-delete `"-`
31//!     - named `"a-"z` (uppercase `"A-"Z` appends instead of overwriting)
32//!     - blackhole `"_`
33//!     - system clipboard `"+` / `"*` (wire to `crate::clipboard::Clipboard`)
34//!     - read-only `":`, `".`, `"%` — surface in `:reg` output
35//! - TODO: route every yank / cut / paste through the bank. Parser needs
36//!   a `"{reg}` prefix state that captures the target register before a
37//!   count / operator.
38//! - TODO: `m{a-z}` sets a mark in a `HashMap<char, (buffer_id, row, col)>`;
39//!   `'x` jumps to the line (FirstNonBlank), `` `x `` to the exact cell.
40//!   Uppercase marks are global across tabs; lowercase are per-buffer.
41//! - TODO: `''` and `` `` `` jump to the last-jump position; `'[` `']`
42//!   `'<` `'>` bound the last change / visual region.
43//! - TODO: `:reg` and `:marks` ex commands.
44//!
45//! ## P4 — Macros
46//!
47//! - TODO: `q{a-z}` starts recording raw `Input`s into the register;
48//!   next `q` stops.
49//! - TODO: `@{a-z}` replays the register by re-feeding inputs through
50//!   `step`. `@@` repeats the last macro. Nested macros need a sane
51//!   depth cap (e.g. 100) to avoid runaway loops.
52//! - TODO: ensure recording doesn't capture the initial `q{a-z}` itself.
53//!
54//! ## P6 — Polish (still outstanding)
55//!
56//! - TODO: indent operators `>` / `<` (with line + text-object targets).
57//! - TODO: format operator `=` — map to whatever SQL formatter we wire
58//!   up; for now stub that returns the range unchanged with a toast.
59//! - TODO: case operators `gU` / `gu` / `g~` on a range (already have
60//!   single-char `~`).
61//! - TODO: screen motions `H` / `M` / `L` once we track the render
62//!   viewport height inside Editor.
63//! - TODO: scroll-to-cursor motions `zz` / `zt` / `zb`.
64//!
65//! ## Known substrate / divergence notes
66//!
67//! - TODO: insert-mode indent helpers — `Ctrl-t` / `Ctrl-d` (increase /
68//!   decrease indent on current line) and `Ctrl-r <reg>` (paste from a
69//!   register). `Ctrl-r` needs the `RegisterBank` from P3 to be useful.
70//! - TODO: `/` and `?` search prompts still live in `the host/src/lib.rs`.
71//!   The plan calls for moving them into the editor (so the editor owns
72//!   `last_search_pattern` rather than the TUI loop). Safe to defer.
73
74use crate::VimMode;
75use crate::input::{Input, Key};
76
77use crate::buf_helpers::{
78    buf_cursor_pos, buf_line, buf_line_bytes, buf_line_chars, buf_row_count, buf_set_cursor_pos,
79    buf_set_cursor_rc,
80};
81use crate::editor::Editor;
82
83// ─── Modes & parser state ───────────────────────────────────────────────────
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
86pub enum Mode {
87    #[default]
88    Normal,
89    Insert,
90    Visual,
91    VisualLine,
92    /// Column-oriented selection (`Ctrl-V`). Unlike the other visual
93    /// modes this one doesn't use tui-textarea's single-range selection
94    /// — the block corners live in [`VimState::block_anchor`] and the
95    /// live cursor. Operators read the rectangle off those two points.
96    VisualBlock,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Default)]
100pub enum Pending {
101    #[default]
102    None,
103    /// Operator seen; still waiting for a motion / text-object / double-op.
104    /// `count1` is any count pressed before the operator.
105    Op { op: Operator, count1: usize },
106    /// Operator + 'i' or 'a' seen; waiting for the text-object character.
107    OpTextObj {
108        op: Operator,
109        count1: usize,
110        inner: bool,
111    },
112    /// Operator + 'g' seen (for `dgg`).
113    OpG { op: Operator, count1: usize },
114    /// Bare `g` seen in normal/visual — looking for `g`, `e`, `E`, …
115    G,
116    /// Bare `f`/`F`/`t`/`T` — looking for the target char.
117    Find { forward: bool, till: bool },
118    /// Operator + `f`/`F`/`t`/`T` — looking for target char.
119    OpFind {
120        op: Operator,
121        count1: usize,
122        forward: bool,
123        till: bool,
124    },
125    /// `r` pressed — waiting for the replacement char.
126    Replace,
127    /// Visual mode + `i` or `a` pressed — waiting for the text-object
128    /// character to extend the selection over.
129    VisualTextObj { inner: bool },
130    /// Bare `z` seen — looking for `z` (center), `t` (top), `b` (bottom).
131    Z,
132    /// `m` pressed — waiting for the mark letter to set.
133    SetMark,
134    /// `'` pressed — waiting for the mark letter to jump to its line
135    /// (lands on first non-blank, linewise for operators).
136    GotoMarkLine,
137    /// `` ` `` pressed — waiting for the mark letter to jump to the
138    /// exact `(row, col)` stored at set time (charwise for operators).
139    GotoMarkChar,
140    /// `"` pressed — waiting for the register selector. The next char
141    /// (`a`–`z`, `A`–`Z`, `0`–`9`, or `"`) sets `pending_register`.
142    SelectRegister,
143    /// `q` pressed (not currently recording) — waiting for the macro
144    /// register name. The macro records every key after the chord
145    /// resolves, until a bare `q` ends the recording.
146    RecordMacroTarget,
147    /// `@` pressed — waiting for the macro register name to play.
148    /// `count` is the prefix multiplier (`3@a` plays the macro 3
149    /// times); 0 means "no prefix" and is treated as 1.
150    PlayMacroTarget { count: usize },
151    /// `[` pressed in Normal/Visual mode — waiting for the second key.
152    /// Resolves `[[` → `SectionBackward`, `[]` → `SectionEndBackward`.
153    SquareBracketOpen,
154    /// `]` pressed in Normal/Visual mode — waiting for the second key.
155    /// Resolves `]]` → `SectionForward`, `][` → `SectionEndForward`.
156    SquareBracketClose,
157    /// Operator + `[` pending — waiting for second key to pick section motion.
158    OpSquareBracketOpen { op: Operator, count1: usize },
159    /// Operator + `]` pending — waiting for second key to pick section motion.
160    OpSquareBracketClose { op: Operator, count1: usize },
161    /// `s` / `S` in Normal mode with `motion_sneak=true` — waiting for
162    /// the first character of the two-char digraph.
163    /// `forward=true` → `s`; `forward=false` → `S` (backward).
164    SneakFirst { forward: bool, count: usize },
165    /// First sneak char captured; waiting for the second char to complete
166    /// the digraph and jump.
167    SneakSecond {
168        c1: char,
169        forward: bool,
170        count: usize,
171    },
172    /// Operator + `s` / `S` pending — waiting for the first char of the
173    /// two-char sneak digraph (e.g. `d` then `s` then `a` then `b` = `dsab`).
174    OpSneakFirst {
175        op: Operator,
176        count1: usize,
177        forward: bool,
178    },
179    /// Operator + sneak first char captured; waiting for the second char.
180    OpSneakSecond {
181        op: Operator,
182        count1: usize,
183        c1: char,
184        forward: bool,
185    },
186}
187
188// ─── Operator / Motion / TextObject ────────────────────────────────────────
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum Operator {
192    Delete,
193    Change,
194    Yank,
195    /// `gU{motion}` — uppercase the range. Entered via the `g` prefix
196    /// in normal mode or `U` in visual mode.
197    Uppercase,
198    /// `gu{motion}` — lowercase the range. `u` in visual mode.
199    Lowercase,
200    /// `g~{motion}` — toggle case of the range. `~` in visual mode
201    /// (character at the cursor for the single-char `~` command stays
202    /// its own code path in normal mode).
203    ToggleCase,
204    /// `>{motion}` — indent the line range by `shiftwidth` spaces.
205    /// Always linewise, even when the motion is char-wise — mirrors
206    /// vim's behaviour where `>w` indents the current line, not the
207    /// word on it.
208    Indent,
209    /// `<{motion}` — outdent the line range (remove up to
210    /// `shiftwidth` leading spaces per line).
211    Outdent,
212    /// `zf{motion}` / `zf{textobj}` / Visual `zf` — create a closed
213    /// fold spanning the row range. Doesn't mutate the buffer text;
214    /// cursor restores to the operator's start position.
215    Fold,
216    /// `gq{motion}` — reflow the row range to `settings.textwidth`.
217    /// Greedy word-wrap: collapses each paragraph (blank-line-bounded
218    /// run) into space-separated words, then re-emits lines whose
219    /// width stays under `textwidth`. Always linewise, like indent.
220    Reflow,
221    /// `gw{motion}` — same reflow as `gq` but cursor stays at the
222    /// pre-reflow `(row, col)`. If the reflow shrinks the line so the
223    /// original col is past the new EOL, the col is clamped to the last
224    /// char of the line (vim's behaviour). Always linewise.
225    ReflowKeepCursor,
226    /// `={motion}` — auto-indent the line range using shiftwidth-based
227    /// bracket depth counting (v1 dumb reindent). Always linewise.
228    /// See `auto_indent_range` for the algorithm and its limitations.
229    AutoIndent,
230    /// `!{motion}` — filter the line range through an external shell command.
231    /// The range text is piped to the command's stdin; stdout replaces the
232    /// range in the buffer. Non-zero exit or spawn failure returns an error
233    /// to the caller without mutating the buffer.
234    Filter,
235    /// `gc{motion}` / `gcc` — toggle line comments on the row range.
236    /// Dispatched through `Editor::toggle_comment_range` rather than the
237    /// normal `run_operator_over_range` pipeline (same pattern as `Filter`).
238    Comment,
239    /// `g?{motion}` / `g??` / visual `g?` — ROT13 the range. Same operator
240    /// shape as the case ops; only the per-char transform differs.
241    Rot13,
242}
243
244/// ROT13 a string: rotate ASCII letters by 13, leave everything else.
245pub(crate) fn rot13_str(s: &str) -> String {
246    s.chars()
247        .map(|c| match c {
248            'a'..='z' => (((c as u8 - b'a' + 13) % 26) + b'a') as char,
249            'A'..='Z' => (((c as u8 - b'A' + 13) % 26) + b'A') as char,
250            _ => c,
251        })
252        .collect()
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum Motion {
257    Left,
258    Right,
259    Up,
260    Down,
261    WordFwd,
262    BigWordFwd,
263    WordBack,
264    BigWordBack,
265    WordEnd,
266    BigWordEnd,
267    /// `ge` — backward word end.
268    WordEndBack,
269    /// `gE` — backward WORD end.
270    BigWordEndBack,
271    LineStart,
272    FirstNonBlank,
273    LineEnd,
274    FileTop,
275    FileBottom,
276    Find {
277        ch: char,
278        forward: bool,
279        till: bool,
280    },
281    FindRepeat {
282        reverse: bool,
283    },
284    MatchBracket,
285    /// `[(` / `])` / `[{` / `]}` — jump to the previous/next unmatched bracket
286    /// of the given kind. `open` is the open char (`(` or `{`); `forward` picks
287    /// the close (`)`/`}`) when true, the open when false.
288    UnmatchedBracket {
289        forward: bool,
290        open: char,
291    },
292    WordAtCursor {
293        forward: bool,
294        /// `*` / `#` use `\bword\b` boundaries; `g*` / `g#` drop them so
295        /// the search hits substrings (e.g. `foo` matches inside `foobar`).
296        whole_word: bool,
297    },
298    /// `n` / `N` — repeat the last `/` or `?` search.
299    SearchNext {
300        reverse: bool,
301    },
302    /// `H` — cursor to viewport top (plus `count - 1` rows down).
303    ViewportTop,
304    /// `M` — cursor to viewport middle.
305    ViewportMiddle,
306    /// `L` — cursor to viewport bottom (minus `count - 1` rows up).
307    ViewportBottom,
308    /// `g_` — last non-blank char on the line.
309    LastNonBlank,
310    /// `gM` — cursor to the middle char column of the current line
311    /// (`floor(chars / 2)`). Vim's variant ignoring screen wrap.
312    LineMiddle,
313    /// `{` — previous paragraph (preceding blank line, or top).
314    ParagraphPrev,
315    /// `}` — next paragraph (following blank line, or bottom).
316    ParagraphNext,
317    /// `(` — previous sentence boundary.
318    SentencePrev,
319    /// `)` — next sentence boundary.
320    SentenceNext,
321    /// `gj` — `count` visual rows down (one screen segment per step
322    /// under `:set wrap`; falls back to `Down` otherwise).
323    ScreenDown,
324    /// `gk` — `count` visual rows up; mirror of [`Motion::ScreenDown`].
325    ScreenUp,
326    /// `[[` — backward to the previous `{` at column 0 (C section header).
327    /// Charwise exclusive; count-aware.
328    SectionBackward,
329    /// `]]` — forward to the next `{` at column 0. Charwise exclusive.
330    SectionForward,
331    /// `[]` — backward to the previous `}` at column 0 (C section end).
332    /// Charwise exclusive; count-aware.
333    SectionEndBackward,
334    /// `][` — forward to the next `}` at column 0. Charwise exclusive.
335    SectionEndForward,
336    /// `+` / `<CR>` — first non-blank of the next line. Linewise.
337    FirstNonBlankNextLine,
338    /// `-` — first non-blank of the previous line. Linewise.
339    FirstNonBlankPrevLine,
340    /// `_` — first non-blank of `count-1` lines down (count=1 = current line). Linewise.
341    FirstNonBlankLine,
342    /// `{count}|` — jump to column `count` on the current line (1-based;
343    /// no count or count=0 → column 1 → index 0). Clamped to line length.
344    GotoColumn,
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum TextObject {
349    Word {
350        big: bool,
351    },
352    Quote(char),
353    Bracket(char),
354    Paragraph,
355    /// `it` / `at` — XML/HTML-style tag pair. `inner = true` covers
356    /// content between `>` and `</`; `inner = false` covers the open
357    /// tag through the close tag inclusive.
358    XmlTag,
359    /// `is` / `as` — sentence: a run ending at `.`, `?`, or `!`
360    /// followed by whitespace or end-of-line. `inner = true` covers
361    /// the sentence text only; `inner = false` includes trailing
362    /// whitespace.
363    Sentence,
364}
365
366/// Classification determines how operators treat the range end.
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368pub enum RangeKind {
369    /// Range end is exclusive (end column not included). Typical: h, l, w, 0, $.
370    Exclusive,
371    /// Range end is inclusive. Typical: e, f, t, %.
372    Inclusive,
373    /// Whole lines from top row to bottom row. Typical: j, k, gg, G.
374    Linewise,
375}
376
377// ─── Dot-repeat storage ────────────────────────────────────────────────────
378
379/// Information needed to replay a mutating change via `.`.
380#[derive(Debug, Clone)]
381pub enum LastChange {
382    /// Operator over a motion.
383    OpMotion {
384        op: Operator,
385        motion: Motion,
386        count: usize,
387        inserted: Option<String>,
388    },
389    /// Operator over a text-object.
390    OpTextObj {
391        op: Operator,
392        obj: TextObject,
393        inner: bool,
394        inserted: Option<String>,
395    },
396    /// `dd`, `cc`, `yy` with a count.
397    LineOp {
398        op: Operator,
399        count: usize,
400        inserted: Option<String>,
401    },
402    /// `x`, `X` with a count.
403    CharDel { forward: bool, count: usize },
404    /// `r<ch>` with a count.
405    ReplaceChar { ch: char, count: usize },
406    /// `~` with a count.
407    ToggleCase { count: usize },
408    /// `J` with a count.
409    JoinLine { count: usize },
410    /// `p` / `P` (and `gp`/`gP`, `]p`/`[p`) with a count.
411    Paste {
412        before: bool,
413        count: usize,
414        /// `gp` / `gP` — leave the cursor just after the pasted text.
415        cursor_after: bool,
416        /// `]p` / `[p` — reindent the pasted block to the current line.
417        reindent: bool,
418    },
419    /// `D` (delete to EOL).
420    DeleteToEol { inserted: Option<String> },
421    /// `o` / `O` + the inserted text.
422    OpenLine { above: bool, inserted: String },
423    /// `i`/`I`/`a`/`A` + inserted text.
424    InsertAt {
425        entry: InsertEntry,
426        inserted: String,
427        count: usize,
428    },
429    /// `dgn` / `cgn` (and `gN` forms) — operate on the next search match.
430    /// `inserted` is filled on Esc for the `cgn` change form so `.` retypes it.
431    GnOp {
432        op: Operator,
433        forward: bool,
434        inserted: Option<String>,
435    },
436    /// `R{text}<Esc>` — replace (overstrike) mode. `.` re-overtypes `text`.
437    ReplaceMode { text: String },
438}
439
440#[derive(Debug, Clone, Copy, PartialEq, Eq)]
441pub enum InsertEntry {
442    I,
443    A,
444    ShiftI,
445    ShiftA,
446}
447
448// ─── VimState ──────────────────────────────────────────────────────────────
449
450/// Tracks which kind of horizontal jump was last performed so `;` / `,`
451/// can dispatch to the correct repeat handler.
452///
453/// - `FindChar` — last horizontal motion was `f`/`F`/`t`/`T`; `;`/`,`
454///   repeats via `Motion::FindRepeat`.
455/// - `Sneak` — last horizontal motion was `s`/`S` sneak; `;`/`,` repeats
456///   via `apply_sneak` with the stored digraph.
457/// - `None` — no horizontal motion yet; `;`/`,` are no-ops for both.
458#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
459pub enum LastHorizontalMotion {
460    #[default]
461    None,
462    FindChar,
463    Sneak,
464}
465
466/// A single abbreviation entry (insert-mode or cmdline-mode, recursive or noremap).
467///
468/// Mode flags: `insert` = expand in Insert mode, `cmdline` = expand in Cmdline mode.
469/// `noremap` stores whether the definition was made with `noreabbrev`; expansion
470/// is always literal text regardless of this flag, but it is preserved for future use.
471///
472/// NOTE: Abbreviations are currently per-editor (global in vim would share across
473/// buffers; per-editor is equivalent for single-buffer use and is acceptable for
474/// now — cross-buffer global behaviour is a follow-up).
475#[derive(Debug, Clone)]
476pub struct Abbrev {
477    pub lhs: String,
478    pub rhs: String,
479    pub insert: bool,
480    pub cmdline: bool,
481    pub noremap: bool,
482}
483
484/// Trigger kind for abbreviation expansion.
485#[derive(Debug, Clone, Copy, PartialEq, Eq)]
486pub enum AbbrevTrigger {
487    /// A non-keyword character was typed (e.g. space, punctuation).
488    NonKeyword(char),
489    /// `<C-]>` was pressed — expand without inserting any character.
490    CtrlBracket,
491    /// `<CR>` (Enter) was pressed.
492    Cr,
493    /// `<Esc>` was pressed to leave insert mode.
494    Esc,
495}
496
497#[derive(Default)]
498pub struct VimState {
499    /// Internal FSM mode. Kept in sync with `current_mode` after every
500    /// `step`. Phase 6.6b: promoted from private to `pub` so the FSM
501    /// body (moving to hjkl-vim in 6.6c–6.6g) can read/write it directly
502    /// until the migration is complete.
503    pub mode: Mode,
504    /// Two-key chord in progress. `Pending::None` when idle.
505    pub pending: Pending,
506    /// Digit prefix accumulated before an operator or motion. `0` means
507    /// no prefix was typed (treated as 1 by most commands).
508    pub count: usize,
509    /// Last `f`/`F`/`t`/`T` target, for `;` / `,` repeat.
510    pub last_find: Option<(char, bool, bool)>,
511    /// Most-recent mutating command for `.` dot-repeat.
512    pub last_change: Option<LastChange>,
513    /// Captured on insert-mode entry: count, buffer snapshot, entry kind.
514    pub insert_session: Option<InsertSession>,
515    /// (row, col) anchor for char-wise Visual mode. Set on entry, used
516    /// to compute the highlight range and the operator range without
517    /// relying on tui-textarea's live selection.
518    pub visual_anchor: (usize, usize),
519    /// Row anchor for VisualLine mode.
520    pub visual_line_anchor: usize,
521    /// (row, col) anchor for VisualBlock mode. The live cursor is the
522    /// opposite corner.
523    pub block_anchor: (usize, usize),
524    /// Intended "virtual" column for the block's active corner. j/k
525    /// clamp cursor.col to shorter rows, which would collapse the
526    /// block across ragged content — so we remember the desired column
527    /// separately and use it for block bounds / insert-column
528    /// computations. Updated by h/l only.
529    pub block_vcol: usize,
530    /// Track whether the last yank/cut was linewise (drives `p`/`P` layout).
531    pub yank_linewise: bool,
532    /// Active register selector — set by `"reg` prefix, consumed by
533    /// the next y / d / c / p. `None` falls back to the unnamed `"`.
534    pub pending_register: Option<char>,
535    /// Recording target — set by `q{reg}`, cleared by a bare `q`.
536    /// While `Some`, every consumed `Input` is appended to
537    /// `recording_keys`.
538    pub recording_macro: Option<char>,
539    /// Keys recorded into the in-progress macro. On `q` finish, these
540    /// are encoded via [`crate::input::encode_macro`] and written to
541    /// the matching named register slot, so macros and yanks share a
542    /// single store.
543    pub recording_keys: Vec<crate::input::Input>,
544    /// Set during `@reg` replay so the recorder doesn't capture the
545    /// replayed keystrokes a second time.
546    pub replaying_macro: bool,
547    /// Last register played via `@reg`. `@@` re-plays this one.
548    pub last_macro: Option<char>,
549    /// Position of the most recent buffer mutation. Surfaced via
550    /// the `'.` / `` `. `` marks for quick "back to last edit".
551    pub last_edit_pos: Option<(usize, usize)>,
552    /// Position where the cursor was when insert mode last exited (Esc).
553    /// Used by `gi` to return to the exact (row, col) where the user
554    /// last typed, matching vim's `:h gi`.
555    pub last_insert_pos: Option<(usize, usize)>,
556    /// Bounded ring of recent edit positions (newest at the back).
557    /// `g;` walks toward older entries, `g,` toward newer ones. Capped
558    /// at [`CHANGE_LIST_MAX`].
559    pub change_list: Vec<(usize, usize)>,
560    /// Index into `change_list` while walking. `None` outside a walk —
561    /// any new edit clears it (and trims forward entries past it).
562    pub change_list_cursor: Option<usize>,
563    /// Snapshot of the last visual selection for `gv` re-entry.
564    /// Stored on every Visual / VisualLine / VisualBlock exit.
565    pub last_visual: Option<LastVisual>,
566    /// `zz` / `zt` / `zb` set this so the end-of-step scrolloff
567    /// pass doesn't override the user's explicit viewport pinning.
568    /// Cleared every step.
569    pub viewport_pinned: bool,
570    /// Set while replaying `.` / last-change so we don't re-record it.
571    pub replaying: bool,
572    /// Entered Normal from Insert via `Ctrl-o`; after the next complete
573    /// normal-mode command we return to Insert.
574    pub one_shot_normal: bool,
575    /// Live `/` or `?` prompt. `None` outside search-prompt mode.
576    pub search_prompt: Option<SearchPrompt>,
577    /// Most recent committed search pattern. Surfaced to host apps via
578    /// [`Editor::last_search`] so their status line can render a hint
579    /// and so `n` / `N` have something to repeat.
580    pub last_search: Option<String>,
581    /// Direction of the last committed search. `n` repeats this; `N`
582    /// inverts it. Defaults to forward so a never-searched buffer's
583    /// `n` still walks downward.
584    pub last_search_forward: bool,
585    /// Text of the most recent insert session — vim's `".` register, pasted
586    /// via `<C-r>.` in insert mode (and `".p` in normal mode).
587    pub last_insert_text: Option<String>,
588    /// Back half of the jumplist — `Ctrl-o` pops from here. Populated
589    /// with the pre-motion cursor when a "big jump" motion fires
590    /// (`gg`/`G`, `%`, `*`/`#`, `n`/`N`, `H`/`M`/`L`, committed `/` or
591    /// `?`). Capped at 100 entries.
592    pub jump_back: Vec<(usize, usize)>,
593    /// Forward half — `Ctrl-i` pops from here. Cleared by any new big
594    /// jump, matching vim's "branch off trims forward history" rule.
595    pub jump_fwd: Vec<(usize, usize)>,
596    /// Set by `Ctrl-R` in insert mode while waiting for the register
597    /// selector. The next typed char names the register; its contents
598    /// are inserted inline at the cursor and the flag clears.
599    pub insert_pending_register: bool,
600    /// Stashed start position for the `[` mark on a Change operation.
601    /// Set to `top` before the cut in `run_operator_over_range` (Change
602    /// arm); consumed by `finish_insert_session` on Esc-from-insert
603    /// when the reason is `AfterChange`. Mirrors vim's `:h '[` / `:h ']`
604    /// rule that `[` = start of change, `]` = last typed char on exit.
605    pub change_mark_start: Option<(usize, usize)>,
606    /// Bounded history of committed `/` / `?` search patterns. Newest
607    /// entries are at the back; capped at [`SEARCH_HISTORY_MAX`] to
608    /// avoid unbounded growth on long sessions.
609    pub search_history: Vec<String>,
610    /// Index into `search_history` while the user walks past patterns
611    /// in the prompt via `Ctrl-P` / `Ctrl-N`. `None` outside that walk
612    /// — typing or backspacing in the prompt resets it so the next
613    /// `Ctrl-P` starts from the most recent entry again.
614    pub search_history_cursor: Option<usize>,
615    /// Wall-clock instant of the last keystroke. Drives the
616    /// `:set timeoutlen` multi-key timeout — if `now() - last_input_at`
617    /// exceeds the configured budget, any pending prefix is cleared
618    /// before the new key dispatches. `None` before the first key.
619    /// 0.0.29 (Patch B): `:set timeoutlen` math now reads
620    /// [`crate::types::Host::now`] via `last_input_host_at`. This
621    /// `Instant`-flavoured field stays for snapshot tests that still
622    /// observe it directly.
623    pub last_input_at: Option<std::time::Instant>,
624    /// `Host::now()` reading at the last keystroke. Drives
625    /// `:set timeoutlen` so macro replay / headless drivers stay
626    /// deterministic regardless of wall-clock skew.
627    pub last_input_host_at: Option<core::time::Duration>,
628    /// Canonical current mode. Mirrors `mode` (the FSM-internal field)
629    /// AND is written by every Phase 6.3 primitive (`set_mode`,
630    /// `enter_visual_char_bridge`, …). Once the FSM is gone this is the
631    /// sole source of truth; until then both fields are kept in sync.
632    /// Initialized to `Normal` via `#[derive(Default)]`.
633    pub(crate) current_mode: crate::VimMode,
634    /// Read-only view overlay layered over `current_mode` (git blame, …).
635    /// Orthogonal to the input mode: while `Blame`, input is still
636    /// interpreted as Normal but mutations are blocked and the host renders
637    /// the overlay. Auto-reset to `Normal` whenever the input mode leaves
638    /// `Normal` (see `drop_blame_if_left_normal`). Initialized to `Normal`.
639    pub(crate) view: crate::ViewMode,
640    /// Most recent successful :s invocation. Stored so :& / :&& can repeat it.
641    pub last_substitute: Option<crate::substitute::SubstituteCmd>,
642    /// Stack of auto-inserted closing characters awaiting skip-over.
643    ///
644    /// Each entry `(row, col, ch)` records where autopair placed a close
645    /// character. When the next typed char matches `ch` AND the cursor is
646    /// immediately before that position, the engine advances past it
647    /// ("skip-over") instead of inserting. The stack is cleared on any
648    /// cursor motion, mode change, or out-of-pair edit.
649    pub pending_closes: Vec<(usize, usize, char)>,
650    /// Last sneak digraph and direction: `Some(((c1, c2), forward))`.
651    /// Used by `;` / `,` sneak-repeat when `last_horizontal_motion == Sneak`.
652    pub last_sneak: Option<((char, char), bool)>,
653    /// Tracks which kind of horizontal motion was last performed, so `;` / `,`
654    /// can dispatch to sneak-repeat vs. find-char-repeat as appropriate.
655    pub last_horizontal_motion: LastHorizontalMotion,
656    /// Insert-mode (and cmdline-mode) abbreviations. Populated by `:abbreviate`,
657    /// `:iabbrev`, `:cabbrev`, `:noreabbrev`, etc. Empty by default.
658    pub abbrevs: Vec<Abbrev>,
659}
660
661pub(crate) const SEARCH_HISTORY_MAX: usize = 100;
662pub(crate) const CHANGE_LIST_MAX: usize = 100;
663
664/// Active `/` or `?` search prompt. Text mutations drive the textarea's
665/// live search pattern so matches highlight as the user types.
666#[derive(Debug, Clone)]
667pub struct SearchPrompt {
668    pub text: String,
669    pub cursor: usize,
670    pub forward: bool,
671    /// Operator-pending search (`d/pat`, `c/pat`, `y/pat`): the operator, its
672    /// count, and the cursor position where the operator started. `None` for a
673    /// plain `/` / `?` search. On commit the operator runs over the (exclusive,
674    /// charwise) range from `origin` to the match.
675    pub operator: Option<(Operator, usize, (usize, usize))>,
676}
677
678#[derive(Debug, Clone)]
679pub struct InsertSession {
680    pub count: usize,
681    /// Min/max row visited during this session. Widens on every key.
682    pub row_min: usize,
683    pub row_max: usize,
684    /// O(1) rope snapshot of the full buffer at session entry. Used to
685    /// diff the affected row window at finish without being fooled by
686    /// cursor navigation through rows the user never edited.
687    /// `ropey::Rope::clone` is Arc-clone — no byte copying.
688    pub before_rope: ropey::Rope,
689    pub reason: InsertReason,
690    /// (row, col) where the insert session began (char-indexed). Abbreviation
691    /// expansion uses `start_col` as `mincol` — only chars at or after this
692    /// column on `start_row` are eligible as part of the `lhs` match, so
693    /// pre-existing buffer text is never consumed by expansion.
694    pub start_row: usize,
695    pub start_col: usize,
696}
697
698#[derive(Debug, Clone)]
699pub enum InsertReason {
700    /// Plain entry via i/I/a/A — recorded as `InsertAt`.
701    Enter(InsertEntry),
702    /// Entry via `o`/`O` — records OpenLine on Esc.
703    Open { above: bool },
704    /// Entry via an operator's change side-effect. Retro-fills the
705    /// stored last-change's `inserted` field on Esc.
706    AfterChange,
707    /// Entry via `C` (delete to EOL + insert).
708    DeleteToEol,
709    /// Entry via an insert triggered during dot-replay — don't touch
710    /// last_change because the outer replay will restore it.
711    ReplayOnly,
712    /// `I` or `A` from VisualBlock: insert the typed text at `col` on
713    /// every row in `top..=bot`. `col` is the start column for `I`, the
714    /// one-past-block-end column for `A`.
715    BlockEdge { top: usize, bot: usize, col: usize },
716    /// `c` from VisualBlock: block content deleted, then user types
717    /// replacement text replicated across all block rows on Esc. Cursor
718    /// advances to the last typed char after replication (unlike BlockEdge
719    /// which leaves cursor at the insertion column).
720    BlockChange { top: usize, bot: usize, col: usize },
721    /// `R` — Replace mode. Each typed char overwrites the cell under
722    /// the cursor instead of inserting; at end-of-line the session
723    /// falls through to insert (same as vim).
724    Replace,
725}
726
727/// Saved visual-mode anchor + cursor for `gv` (re-enters the last
728/// visual selection). `mode` carries which visual flavour to
729/// restore; `anchor` / `cursor` mean different things per flavour:
730///
731/// - `Visual`     — `anchor` is the char-wise visual anchor.
732/// - `VisualLine` — `anchor.0` is the `visual_line_anchor` row;
733///   `anchor.1` is unused.
734/// - `VisualBlock`— `anchor` is `block_anchor`, `block_vcol` is the
735///   sticky vcol that survives j/k clamping.
736#[derive(Debug, Clone, Copy)]
737pub struct LastVisual {
738    pub mode: Mode,
739    pub anchor: (usize, usize),
740    pub cursor: (usize, usize),
741    pub block_vcol: usize,
742}
743
744impl VimState {
745    pub fn public_mode(&self) -> VimMode {
746        match self.mode {
747            Mode::Normal => VimMode::Normal,
748            Mode::Insert => VimMode::Insert,
749            Mode::Visual => VimMode::Visual,
750            Mode::VisualLine => VimMode::VisualLine,
751            Mode::VisualBlock => VimMode::VisualBlock,
752        }
753    }
754
755    pub fn force_normal(&mut self) {
756        self.mode = Mode::Normal;
757        self.pending = Pending::None;
758        self.count = 0;
759        self.insert_session = None;
760        // Phase 6.3: keep current_mode in sync for callers that bypass step().
761        self.current_mode = crate::VimMode::Normal;
762    }
763
764    /// Reset every prefix-tracking field so the next keystroke starts
765    /// a fresh sequence. Drives `:set timeoutlen` — when the user
766    /// pauses past the configured budget, `hjkl_vim::dispatch_input` calls
767    /// this before dispatching the new key.
768    ///
769    /// Resets: `pending`, `count`, `pending_register`,
770    /// `insert_pending_register`. Does NOT touch `mode`,
771    /// `insert_session`, marks, jump list, or visual anchors —
772    /// those aren't part of the in-flight chord.
773    pub(crate) fn clear_pending_prefix(&mut self) {
774        self.pending = Pending::None;
775        self.count = 0;
776        self.pending_register = None;
777        self.insert_pending_register = false;
778    }
779
780    /// Widen the active insert session's row window to include `row`. Called
781    /// by the Phase 6.1 public `Editor::insert_*` methods after each
782    /// mutation so `finish_insert_session` diffs the right range on Esc.
783    /// No-op when no insert session is active (e.g. calling from Normal mode).
784    pub(crate) fn widen_insert_row(&mut self, row: usize) {
785        if let Some(ref mut session) = self.insert_session {
786            session.row_min = session.row_min.min(row);
787            session.row_max = session.row_max.max(row);
788        }
789    }
790
791    pub fn is_visual(&self) -> bool {
792        matches!(
793            self.mode,
794            Mode::Visual | Mode::VisualLine | Mode::VisualBlock
795        )
796    }
797
798    pub fn is_visual_char(&self) -> bool {
799        self.mode == Mode::Visual
800    }
801
802    /// The pending repeat count (typed digits before a motion/operator),
803    /// or `None` when no digits are pending. Zero is treated as absent.
804    pub(crate) fn pending_count_val(&self) -> Option<u32> {
805        if self.count == 0 {
806            None
807        } else {
808            Some(self.count as u32)
809        }
810    }
811
812    /// `true` when an in-flight chord is awaiting more keys. Inverse of
813    /// `matches!(self.pending, Pending::None)`.
814    pub(crate) fn is_chord_pending(&self) -> bool {
815        !matches!(self.pending, Pending::None)
816    }
817
818    /// Return a single char representing the pending operator, if any.
819    /// Used by host apps (status line "showcmd" area) to display e.g.
820    /// `d`, `y`, `c` while waiting for a motion.
821    pub(crate) fn pending_op_char(&self) -> Option<char> {
822        let op = match &self.pending {
823            Pending::Op { op, .. }
824            | Pending::OpTextObj { op, .. }
825            | Pending::OpG { op, .. }
826            | Pending::OpFind { op, .. }
827            | Pending::OpSquareBracketOpen { op, .. }
828            | Pending::OpSquareBracketClose { op, .. } => Some(*op),
829            _ => None,
830        };
831        op.map(|o| match o {
832            Operator::Delete => 'd',
833            Operator::Change => 'c',
834            Operator::Yank => 'y',
835            Operator::Uppercase => 'U',
836            Operator::Lowercase => 'u',
837            Operator::ToggleCase => '~',
838            Operator::Indent => '>',
839            Operator::Outdent => '<',
840            Operator::Fold => 'z',
841            Operator::Reflow => 'q',
842            Operator::ReflowKeepCursor => 'w',
843            Operator::AutoIndent => '=',
844            Operator::Filter => '!',
845            // `gc` prefix — doubled as `gcc`.
846            Operator::Comment => 'c',
847            // `g?` prefix — doubled as `g??`.
848            Operator::Rot13 => '?',
849        })
850    }
851}
852
853// ─── Entry point ───────────────────────────────────────────────────────────
854
855/// Open the `/` (forward) or `?` (backward) search prompt. Clears any
856/// live search highlight until the user commits a query. `last_search`
857/// is preserved so an empty `<CR>` can re-run the previous pattern.
858pub(crate) fn enter_search<H: crate::types::Host>(
859    ed: &mut Editor<hjkl_buffer::Buffer, H>,
860    forward: bool,
861) {
862    ed.vim.search_prompt = Some(SearchPrompt {
863        text: String::new(),
864        cursor: 0,
865        forward,
866        operator: None,
867    });
868    ed.vim.search_history_cursor = None;
869    // 0.0.37: clear via the engine search state (the buffer-side
870    // bridge from 0.0.35 was removed in this patch — the `BufferView`
871    // renderer reads the pattern from `Editor::search_state()`).
872    ed.set_search_pattern(None);
873}
874
875/// `d/pat` / `c/pat` / `y/pat` (and `?` forms) — open the search prompt in
876/// operator-pending mode. On commit the operator runs over the exclusive
877/// charwise range from the current cursor to the match.
878pub(crate) fn enter_search_op<H: crate::types::Host>(
879    ed: &mut Editor<hjkl_buffer::Buffer, H>,
880    forward: bool,
881    op: Operator,
882    count: usize,
883) {
884    let origin = ed.cursor();
885    ed.vim.search_prompt = Some(SearchPrompt {
886        text: String::new(),
887        cursor: 0,
888        forward,
889        operator: Some((op, count.max(1), origin)),
890    });
891    ed.vim.search_history_cursor = None;
892    ed.set_search_pattern(None);
893}
894
895/// Apply a pending operator-search over the exclusive charwise range from
896/// `origin` to the current (post-search) cursor. Used by the search-prompt
897/// commit path for `d/` / `c/` / `y/`.
898pub(crate) fn apply_op_search_range<H: crate::types::Host>(
899    ed: &mut Editor<hjkl_buffer::Buffer, H>,
900    op: Operator,
901    origin: (usize, usize),
902) {
903    let target = ed.cursor();
904    run_operator_over_range(ed, op, origin, target, RangeKind::Exclusive);
905}
906
907/// `g;` / `g,` body. `dir = -1` walks toward older entries (g;),
908/// `dir = 1` toward newer (g,). `count` repeats the step. Stops at
909/// the ends of the ring; off-ring positions are silently ignored.
910fn walk_change_list<H: crate::types::Host>(
911    ed: &mut Editor<hjkl_buffer::Buffer, H>,
912    dir: isize,
913    count: usize,
914) {
915    if ed.vim.change_list.is_empty() {
916        return;
917    }
918    let len = ed.vim.change_list.len();
919    let mut idx: isize = match (ed.vim.change_list_cursor, dir) {
920        (None, -1) => len as isize - 1,
921        (None, 1) => return, // already past the newest entry
922        (Some(i), -1) => i as isize - 1,
923        (Some(i), 1) => i as isize + 1,
924        _ => return,
925    };
926    for _ in 1..count {
927        let next = idx + dir;
928        if next < 0 || next >= len as isize {
929            break;
930        }
931        idx = next;
932    }
933    if idx < 0 || idx >= len as isize {
934        return;
935    }
936    let idx = idx as usize;
937    ed.vim.change_list_cursor = Some(idx);
938    let (row, col) = ed.vim.change_list[idx];
939    ed.jump_cursor(row, col);
940}
941
942/// `Ctrl-R {reg}` body — insert the named register's contents at the
943/// cursor as charwise text. Embedded newlines split lines naturally via
944/// `Edit::InsertStr`. Unknown selectors and empty slots are no-ops so
945/// stray keystrokes don't mutate the buffer.
946fn insert_register_text<H: crate::types::Host>(
947    ed: &mut Editor<hjkl_buffer::Buffer, H>,
948    selector: char,
949) {
950    use hjkl_buffer::Edit;
951    // Special read-only registers: `/` = last search pattern, `.` = last
952    // inserted text. Fall back to the register store for everything else.
953    let text = match selector {
954        '/' => match &ed.vim.last_search {
955            Some(s) if !s.is_empty() => s.clone(),
956            _ => return,
957        },
958        '.' => match &ed.vim.last_insert_text {
959            Some(s) if !s.is_empty() => s.clone(),
960            _ => return,
961        },
962        _ => match ed.registers().read(selector) {
963            Some(slot) if !slot.text.is_empty() => slot.text.clone(),
964            _ => return,
965        },
966    };
967    ed.sync_buffer_content_from_textarea();
968    let cursor = buf_cursor_pos(&ed.buffer);
969    ed.mutate_edit(Edit::InsertStr {
970        at: cursor,
971        text: text.clone(),
972    });
973    // Advance cursor to the end of the inserted payload — multi-line
974    // pastes land on the last inserted row at the post-text column.
975    let mut row = cursor.row;
976    let mut col = cursor.col;
977    for ch in text.chars() {
978        if ch == '\n' {
979            row += 1;
980            col = 0;
981        } else {
982            col += 1;
983        }
984    }
985    buf_set_cursor_rc(&mut ed.buffer, row, col);
986    ed.push_buffer_cursor_to_textarea();
987    ed.mark_content_dirty();
988    if let Some(ref mut session) = ed.vim.insert_session {
989        session.row_min = session.row_min.min(row);
990        session.row_max = session.row_max.max(row);
991    }
992}
993
994/// Compute the indent string to insert at the start of a new line
995/// after Enter is pressed at `cursor`. Walks the smartindent rules:
996///
997/// - autoindent off → empty string
998/// - autoindent on  → copy prev line's leading whitespace
999/// - smartindent on → bump one `shiftwidth` if prev line's last
1000///   non-whitespace char is `{` / `(` / `[`
1001///
1002/// Indent unit (used for the smartindent bump):
1003///
1004/// - `expandtab && softtabstop > 0` → `softtabstop` spaces
1005/// - `expandtab` → `shiftwidth` spaces
1006/// - `!expandtab` → one literal `\t`
1007///
1008/// This is the placeholder for a future tree-sitter indent provider:
1009/// when a language has an `indents.scm` query, the engine will route
1010/// the same call through that provider and only fall back to this
1011/// heuristic when no query matches.
1012pub(super) fn compute_enter_indent(settings: &crate::editor::Settings, prev_line: &str) -> String {
1013    if !settings.autoindent {
1014        return String::new();
1015    }
1016    // Copy the prev line's leading whitespace (autoindent base).
1017    let base: String = prev_line
1018        .chars()
1019        .take_while(|c| *c == ' ' || *c == '\t')
1020        .collect();
1021
1022    if settings.smartindent {
1023        let unit = if settings.expandtab {
1024            if settings.softtabstop > 0 {
1025                " ".repeat(settings.softtabstop)
1026            } else {
1027                " ".repeat(settings.shiftwidth)
1028            }
1029        } else {
1030            "\t".to_string()
1031        };
1032
1033        // Open-bracket bump — language-agnostic.
1034        let last_non_ws = prev_line.chars().rev().find(|c| !c.is_whitespace());
1035        if matches!(last_non_ws, Some('{' | '(' | '[')) {
1036            return format!("{base}{unit}");
1037        }
1038
1039        // HTML-family opening-tag bump: `<head>` / `<div class="...">`.
1040        // Gated on filetype so Rust generics like `Vec<T>` don't trigger.
1041        // Reuses scan_tag_opener which already filters self-closing and
1042        // void elements.
1043        if is_html_filetype(&settings.filetype) {
1044            let trimmed_end_len = prev_line
1045                .trim_end_matches(|c: char| c.is_whitespace())
1046                .len();
1047            let trimmed = &prev_line[..trimmed_end_len];
1048            if let Some(stripped) = trimmed.strip_suffix('>')
1049                && scan_tag_opener(trimmed, stripped.len()).is_some()
1050            {
1051                return format!("{base}{unit}");
1052            }
1053        }
1054    }
1055
1056    base
1057}
1058
1059// ── Comment-continuation helpers ──────────────────────────────────────────
1060
1061/// Return the ordered (longest-first) list of line-comment prefixes for
1062/// `lang`. Each prefix includes one trailing space (e.g. `"// "`).
1063/// The same table lives in `hjkl-lang::comment` for the `gc` toggle (#187).
1064fn comment_prefixes_for_lang(lang: &str) -> &'static [&'static str] {
1065    match lang {
1066        "rust" => &["/// ", "//! ", "// "],
1067        "c" | "cpp" => &["// "],
1068        "python" | "sh" | "bash" | "zsh" | "fish" | "toml" | "yaml" => &["# "],
1069        "lua" => &["-- "],
1070        "sql" => &["-- "],
1071        "vim" | "viml" => &["\" "],
1072        _ => &[],
1073    }
1074}
1075
1076/// Detect whether `line` starts with a known comment prefix for `lang`.
1077///
1078/// Returns `Some((indent, prefix))` where `indent` is the leading whitespace
1079/// of the line and `prefix` is the canonical (with trailing space) comment
1080/// marker. Returns `None` when the line is not a recognised comment.
1081pub(crate) fn detect_comment_on_line(lang: &str, line: &str) -> Option<(String, &'static str)> {
1082    let indent_end = line
1083        .char_indices()
1084        .find(|(_, c)| *c != ' ' && *c != '\t')
1085        .map(|(i, _)| i)
1086        .unwrap_or(line.len());
1087    let indent = line[..indent_end].to_string();
1088    let rest = &line[indent_end..];
1089    for &prefix in comment_prefixes_for_lang(lang) {
1090        if rest.starts_with(prefix) {
1091            return Some((indent, prefix));
1092        }
1093        // Also match the bare prefix (line that is exactly `//` with no
1094        // trailing content).
1095        let bare = prefix.trim_end_matches(' ');
1096        if rest == bare || rest.starts_with(&format!("{bare} ")) {
1097            return Some((indent, prefix));
1098        }
1099    }
1100    None
1101}
1102
1103/// Given the current `row` in `buffer` and the active `settings`, return the
1104/// string to prepend on the new line when comment-continuation fires.
1105///
1106/// Returns `Some("<indent><prefix>")` when the row is a comment line and
1107/// continuation is appropriate, `None` otherwise. The caller appends the
1108/// string after the `\n` they are about to insert.
1109pub(crate) fn continue_comment(
1110    buffer: &hjkl_buffer::Buffer,
1111    settings: &crate::editor::Settings,
1112    row: usize,
1113) -> Option<String> {
1114    if settings.filetype.is_empty() {
1115        return None;
1116    }
1117    let line = crate::buf_helpers::buf_line(buffer, row)?;
1118    let (indent, prefix) = detect_comment_on_line(&settings.filetype, &line)?;
1119    Some(format!("{indent}{prefix}"))
1120}
1121
1122/// Strip one indent unit from the beginning of `line` and insert `ch`
1123/// instead. Returns `true` when it consumed the keystroke (dedent +
1124/// insert), `false` when the caller should insert normally.
1125///
1126/// Dedent fires when:
1127///   - `smartindent` is on
1128///   - `ch` is `}` / `)` / `]`
1129///   - all bytes BEFORE the cursor on the current line are whitespace
1130///   - there is at least one full indent unit of leading whitespace
1131fn try_dedent_close_bracket<H: crate::types::Host>(
1132    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1133    cursor: hjkl_buffer::Position,
1134    ch: char,
1135) -> bool {
1136    use hjkl_buffer::{Edit, MotionKind, Position};
1137
1138    if !ed.settings.smartindent {
1139        return false;
1140    }
1141    if !matches!(ch, '}' | ')' | ']') {
1142        return false;
1143    }
1144
1145    let line = match buf_line(&ed.buffer, cursor.row) {
1146        Some(l) => l.to_string(),
1147        None => return false,
1148    };
1149
1150    // All chars before cursor must be whitespace.
1151    let before: String = line.chars().take(cursor.col).collect();
1152    if !before.chars().all(|c| c == ' ' || c == '\t') {
1153        return false;
1154    }
1155    if before.is_empty() {
1156        // Nothing to strip — just insert normally (cursor at col 0).
1157        return false;
1158    }
1159
1160    // Compute indent unit.
1161    let unit_len: usize = if ed.settings.expandtab {
1162        if ed.settings.softtabstop > 0 {
1163            ed.settings.softtabstop
1164        } else {
1165            ed.settings.shiftwidth
1166        }
1167    } else {
1168        // Tab: one literal tab character.
1169        1
1170    };
1171
1172    // Check there's at least one full unit to strip.
1173    let strip_len = if ed.settings.expandtab {
1174        // Count leading spaces; need at least `unit_len`.
1175        let spaces = before.chars().filter(|c| *c == ' ').count();
1176        if spaces < unit_len {
1177            return false;
1178        }
1179        unit_len
1180    } else {
1181        // noexpandtab: strip one leading tab.
1182        if !before.starts_with('\t') {
1183            return false;
1184        }
1185        1
1186    };
1187
1188    // Delete the leading `strip_len` chars of the current line.
1189    ed.mutate_edit(Edit::DeleteRange {
1190        start: Position::new(cursor.row, 0),
1191        end: Position::new(cursor.row, strip_len),
1192        kind: MotionKind::Char,
1193    });
1194    // Insert the close bracket at column 0 (after the delete the cursor
1195    // is still positioned at the end of the remaining whitespace; the
1196    // delete moved the text so the cursor is now at col = before.len() -
1197    // strip_len).
1198    let new_col = cursor.col.saturating_sub(strip_len);
1199    ed.mutate_edit(Edit::InsertChar {
1200        at: Position::new(cursor.row, new_col),
1201        ch,
1202    });
1203    true
1204}
1205
1206fn finish_insert_session<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
1207    let Some(session) = ed.vim.insert_session.take() else {
1208        return;
1209    };
1210    let after_rope = crate::types::Query::rope(&ed.buffer);
1211    // Clamp both slices to their respective bounds — the buffer may have
1212    // grown (Enter splits rows) or shrunk (Backspace joins rows) during
1213    // the session, so row_max can overshoot either side.
1214    let before_n = session.before_rope.len_lines();
1215    let after_n = after_rope.len_lines();
1216    let after_end = session.row_max.min(after_n.saturating_sub(1));
1217    let before_end = session.row_max.min(before_n.saturating_sub(1));
1218    let before = if before_end >= session.row_min && session.row_min < before_n {
1219        rope_row_range_str(&session.before_rope, session.row_min, before_end)
1220    } else {
1221        String::new()
1222    };
1223    let after = if after_end >= session.row_min && session.row_min < after_n {
1224        rope_row_range_str(&after_rope, session.row_min, after_end)
1225    } else {
1226        String::new()
1227    };
1228    // `R` overstrike keeps the line length the same, so `extract_inserted`
1229    // (which only reports net growth) misses the typed text. Use the changed
1230    // run instead so dot-repeat retypes it.
1231    let inserted = if matches!(session.reason, InsertReason::Replace) {
1232        changed_run(&before, &after)
1233    } else {
1234        extract_inserted(&before, &after)
1235    };
1236    // vim `".` register — text of the most recent insert.
1237    if !ed.vim.replaying && !inserted.is_empty() {
1238        ed.vim.last_insert_text = Some(inserted.clone());
1239    }
1240    let open_line = matches!(session.reason, InsertReason::Open { .. });
1241    if session.count > 1 && !ed.vim.replaying {
1242        use hjkl_buffer::{Edit, Position};
1243        if open_line {
1244            // `[count]o` / `[count]O` open `count` SEPARATE lines, each with the
1245            // typed text. Read the just-opened line's content directly (the
1246            // row-range extract above is unreliable across the open boundary)
1247            // and stack `count - 1` further lines below it.
1248            let (start_row, _) = ed.cursor();
1249            let typed = buf_line(&ed.buffer, start_row).unwrap_or_default();
1250            for at_row in start_row..start_row + (session.count - 1) {
1251                let end = buf_line_chars(&ed.buffer, at_row);
1252                ed.mutate_edit(Edit::InsertStr {
1253                    at: Position::new(at_row, end),
1254                    text: format!("\n{typed}"),
1255                });
1256            }
1257        } else if !inserted.is_empty() {
1258            // `[count]i` / `[count]A` repeat the typed text inline.
1259            for _ in 0..session.count - 1 {
1260                let (row, col) = ed.cursor();
1261                ed.mutate_edit(Edit::InsertStr {
1262                    at: Position::new(row, col),
1263                    text: inserted.clone(),
1264                });
1265            }
1266        }
1267    }
1268    // Helper: replicate `inserted` text across block rows top+1..=bot at `col`,
1269    // padding short rows to reach `col` first. Returns without touching the
1270    // cursor — callers position the cursor afterward according to their needs.
1271    fn replicate_block_text<H: crate::types::Host>(
1272        ed: &mut Editor<hjkl_buffer::Buffer, H>,
1273        inserted: &str,
1274        top: usize,
1275        bot: usize,
1276        col: usize,
1277    ) {
1278        use hjkl_buffer::{Edit, Position};
1279        for r in (top + 1)..=bot {
1280            let line_len = buf_line_chars(&ed.buffer, r);
1281            if col > line_len {
1282                let pad: String = std::iter::repeat_n(' ', col - line_len).collect();
1283                ed.mutate_edit(Edit::InsertStr {
1284                    at: Position::new(r, line_len),
1285                    text: pad,
1286                });
1287            }
1288            ed.mutate_edit(Edit::InsertStr {
1289                at: Position::new(r, col),
1290                text: inserted.to_string(),
1291            });
1292        }
1293    }
1294
1295    if let InsertReason::BlockEdge { top, bot, col } = session.reason {
1296        // `I` / `A` from VisualBlock: replicate text across rows; cursor
1297        // stays at the block-start column (vim leaves cursor there).
1298        if !inserted.is_empty() && top < bot && !ed.vim.replaying {
1299            replicate_block_text(ed, &inserted, top, bot, col);
1300            buf_set_cursor_rc(&mut ed.buffer, top, col);
1301            ed.push_buffer_cursor_to_textarea();
1302        }
1303        return;
1304    }
1305    if let InsertReason::BlockChange { top, bot, col } = session.reason {
1306        // `c` from VisualBlock: replicate text across rows; cursor advances
1307        // to `col + ins_chars` (pre-step-back) so the Esc step-back lands
1308        // on the last typed char (col + ins_chars - 1), matching nvim.
1309        if !inserted.is_empty() && top < bot && !ed.vim.replaying {
1310            replicate_block_text(ed, &inserted, top, bot, col);
1311            let ins_chars = inserted.chars().count();
1312            let line_len = buf_line_chars(&ed.buffer, top);
1313            let target_col = (col + ins_chars).min(line_len);
1314            buf_set_cursor_rc(&mut ed.buffer, top, target_col);
1315            ed.push_buffer_cursor_to_textarea();
1316        }
1317        return;
1318    }
1319    if ed.vim.replaying {
1320        return;
1321    }
1322    match session.reason {
1323        InsertReason::Enter(entry) => {
1324            ed.vim.last_change = Some(LastChange::InsertAt {
1325                entry,
1326                inserted,
1327                count: session.count,
1328            });
1329        }
1330        InsertReason::Open { above } => {
1331            ed.vim.last_change = Some(LastChange::OpenLine { above, inserted });
1332        }
1333        InsertReason::AfterChange => {
1334            if let Some(
1335                LastChange::OpMotion { inserted: ins, .. }
1336                | LastChange::OpTextObj { inserted: ins, .. }
1337                | LastChange::LineOp { inserted: ins, .. }
1338                | LastChange::GnOp { inserted: ins, .. },
1339            ) = ed.vim.last_change.as_mut()
1340            {
1341                *ins = Some(inserted);
1342            }
1343            // Vim `:h '[` / `:h ']`: on change, `[` = start of the
1344            // changed range (stashed before the cut), `]` = the cursor
1345            // at Esc time (last inserted char, before the step-back).
1346            // When nothing was typed cursor still sits at the change
1347            // start, satisfying vim's "both at start" parity for `c<m><Esc>`.
1348            if let Some(start) = ed.vim.change_mark_start.take() {
1349                let end = ed.cursor();
1350                ed.set_mark('[', start);
1351                ed.set_mark(']', end);
1352            }
1353        }
1354        InsertReason::DeleteToEol => {
1355            ed.vim.last_change = Some(LastChange::DeleteToEol {
1356                inserted: Some(inserted),
1357            });
1358        }
1359        InsertReason::ReplayOnly => {}
1360        InsertReason::BlockEdge { .. } => unreachable!("handled above"),
1361        InsertReason::BlockChange { .. } => unreachable!("handled above"),
1362        InsertReason::Replace => {
1363            // `R` overstrike: dot-repeat re-overtypes the same text at the
1364            // cursor (vim parity — not a delete-to-EOL).
1365            ed.vim.last_change = Some(LastChange::ReplaceMode { text: inserted });
1366        }
1367    }
1368}
1369
1370pub(crate) fn begin_insert<H: crate::types::Host>(
1371    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1372    count: usize,
1373    reason: InsertReason,
1374) {
1375    // `nomodifiable`: silently refuse to enter insert/replace; stay in current mode.
1376    if !ed.settings.modifiable {
1377        return;
1378    }
1379    // BLAME view: pressing `i` exits blame (drops the overlay) but stays Normal.
1380    if ed.vim.view == crate::ViewMode::Blame {
1381        ed.vim.view = crate::ViewMode::Normal;
1382        return;
1383    }
1384    let record = !matches!(reason, InsertReason::ReplayOnly);
1385    if record {
1386        ed.push_undo();
1387    }
1388    let reason = if ed.vim.replaying {
1389        InsertReason::ReplayOnly
1390    } else {
1391        reason
1392    };
1393    let (row, col) = ed.cursor();
1394    ed.vim.insert_session = Some(InsertSession {
1395        count,
1396        row_min: row,
1397        row_max: row,
1398        before_rope: crate::types::Query::rope(&ed.buffer),
1399        reason,
1400        start_row: row,
1401        start_col: col,
1402    });
1403    ed.vim.mode = Mode::Insert;
1404    // Phase 6.3: keep current_mode in sync for callers that bypass step().
1405    ed.vim.current_mode = crate::VimMode::Insert;
1406    drop_blame_if_left_normal(ed);
1407}
1408
1409/// `:set undobreak` semantics for insert-mode motions. When the
1410/// toggle is on, a non-character keystroke that moves the cursor
1411/// (arrow keys, Home/End, mouse click) ends the current undo group
1412/// and starts a new one mid-session. After this, a subsequent `u`
1413/// in normal mode reverts only the post-break run, leaving the
1414/// pre-break edits in place — matching vim's behaviour.
1415///
1416/// Implementation: snapshot the current buffer onto the undo stack
1417/// (the new break point) and reset the active `InsertSession`'s
1418/// `before_lines` so `finish_insert_session`'s diff window only
1419/// captures the post-break run for `last_change` / dot-repeat.
1420///
1421/// During replay we skip the break — replay shouldn't pollute the
1422/// undo stack with intra-replay snapshots.
1423pub(crate) fn break_undo_group_in_insert<H: crate::types::Host>(
1424    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1425) {
1426    if !ed.settings.undo_break_on_motion {
1427        return;
1428    }
1429    if ed.vim.replaying {
1430        return;
1431    }
1432    if ed.vim.insert_session.is_none() {
1433        return;
1434    }
1435    ed.push_undo();
1436    let before_rope = crate::types::Query::rope(&ed.buffer);
1437    let row = crate::types::Cursor::cursor(&ed.buffer).line as usize;
1438    if let Some(ref mut session) = ed.vim.insert_session {
1439        session.before_rope = before_rope;
1440        session.row_min = row;
1441        session.row_max = row;
1442    }
1443}
1444
1445// ─── Phase 6.1: public insert-mode primitives ──────────────────────────────
1446//
1447// Each `pub(crate)` free function below implements one insert-mode action.
1448// hjkl-vim's insert dispatcher calls them through `Editor::insert_*` methods.
1449// External callers can also invoke the public Editor methods directly.
1450//
1451// Invariants every function upholds:
1452//   - Opens with `ed.sync_buffer_content_from_textarea()` (no-op, kept for
1453//     forward compatibility once textarea is gone).
1454//   - All buffer mutations go through `ed.mutate_edit(...)` so dirty flag,
1455//     undo, change-list, content-edit fan-out all fire uniformly.
1456//   - Navigation-only functions call `break_undo_group_in_insert` when the
1457//     FSM did so, then return `false` (no mutation).
1458//   - After mutations, `ed.push_buffer_cursor_to_textarea()` is called
1459//     (currently a no-op but kept for migration hygiene).
1460//   - Returns `true` when the buffer was mutated, `false` otherwise.
1461
1462/// Return the filetype-gated autopair close character for `open`, or `None`
1463/// when no pairing applies.
1464///
1465/// Rules:
1466/// - `(` → `)`, `[` → `]`, `{` → `}` always.
1467/// - `"` → `"` and `` ` `` → `` ` `` always, EXCEPT when the previous two
1468///   characters are the same quote — typing the third `` ` `` of a markdown
1469///   code-fence or the third `"` of a Python triple-quoted string must
1470///   emit a bare quote (no close) so the result is `` ``` `` / `"""` and
1471///   not `` ```` `` / `""""`.
1472/// - `<` → `>` only for HTML/XML family filetypes.
1473/// - `'` → `'` unless the character immediately before the cursor is
1474///   `[A-Za-z]` (prose apostrophe guard — "don't" stays "don't"), AND the
1475///   same triple-quote guard as `"` / `` ` ``.
1476fn autopair_close_for(
1477    ch: char,
1478    filetype: &str,
1479    prev_char: Option<char>,
1480    prev2_char: Option<char>,
1481) -> Option<char> {
1482    // Triple-quote guard — applies to ", `, and ' (the three quote chars
1483    // that get same-char pairing). When the previous two characters are
1484    // both this same quote, treat the third keystroke as a bare insert so
1485    // the user lands on `` ``` `` / `"""` / `'''` without a stray fourth
1486    // quote dangling after the cursor.
1487    let is_triple_quote_third =
1488        matches!(ch, '"' | '`' | '\'') && prev_char == Some(ch) && prev2_char == Some(ch);
1489
1490    match ch {
1491        '(' => Some(')'),
1492        '[' => Some(']'),
1493        '{' => Some('}'),
1494        '"' => {
1495            if is_triple_quote_third {
1496                None
1497            } else {
1498                Some('"')
1499            }
1500        }
1501        '`' => {
1502            if is_triple_quote_third {
1503                None
1504            } else {
1505                Some('`')
1506            }
1507        }
1508        '<' => {
1509            if is_html_filetype(filetype) {
1510                Some('>')
1511            } else {
1512                None
1513            }
1514        }
1515        '\'' => {
1516            if is_triple_quote_third {
1517                return None;
1518            }
1519            // Prose guard: skip pairing when the previous char is a letter
1520            // (covers "don't", "it's", etc.).
1521            if prev_char.map(|c| c.is_ascii_alphabetic()).unwrap_or(false) {
1522                None
1523            } else {
1524                Some('\'')
1525            }
1526        }
1527        _ => None,
1528    }
1529}
1530
1531/// Detect a markdown / doc-comment code-fence opener on the current line.
1532///
1533/// Returns `Some(fence)` (the backtick run that should be used as the
1534/// closing fence) when:
1535/// - The cursor is at the end of the visible line (`cursor_col` equals the
1536///   line's char count).
1537/// - The line, after leading whitespace, begins with 3+ backticks followed
1538///   by a non-empty language tag matching `[A-Za-z0-9_+-]+` and nothing
1539///   else (no trailing space, no extra text).
1540///
1541/// The language tag requirement is deliberate: a bare ` ``` ` could be
1542/// either an opener OR a closer, and we don't track fence parity here.
1543/// Requiring a tag means we only fire when the user is clearly opening a
1544/// fence (` ```rust `, ` ```ts `, etc.).
1545fn detect_code_fence_opener(line: &str, cursor_col: usize) -> Option<String> {
1546    if cursor_col != line.chars().count() {
1547        return None;
1548    }
1549    let trimmed = line.trim_start();
1550    let backtick_run = trimmed.chars().take_while(|c| *c == '`').count();
1551    if backtick_run < 3 {
1552        return None;
1553    }
1554    let rest = &trimmed[backtick_run..];
1555    if rest.is_empty() {
1556        return None;
1557    }
1558    let all_lang_chars = rest
1559        .chars()
1560        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '+' || c == '-');
1561    if !all_lang_chars {
1562        return None;
1563    }
1564    Some("`".repeat(backtick_run))
1565}
1566
1567/// Filetypes that get HTML/XML-family treatment (`<` pairing + tag autoclose).
1568fn is_html_filetype(ft: &str) -> bool {
1569    matches!(
1570        ft,
1571        "html" | "xml" | "svg" | "jsx" | "tsx" | "vue" | "svelte"
1572    )
1573}
1574
1575// ── Paired-tag auto-rename (issue #182) ────────────────────────────────────
1576//
1577// When the user edits the name of an HTML/XML opening tag (e.g. `ci<` to
1578// change-inner the tag name, type a new name, then `<Esc>`), the matching
1579// closing tag should rename automatically so the pair stays in sync.
1580// Same on the close side: edit `</X>` → its opener gets renamed.
1581//
1582// Trigger: leave_insert_to_normal_bridge calls sync_paired_tag_on_exit, which
1583// inspects the cursor's current position. If the cursor sits inside a tag
1584// name and the paired tag has a different name, rewrite the paired tag.
1585//
1586// Pairing uses a stack-based scan so nested same-name tags
1587// (`<div><div></div></div>`) pair correctly.
1588
1589/// Tag kind detected at a cursor position.
1590#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1591enum TagKind {
1592    Open,
1593    Close,
1594}
1595
1596/// A single tag instance located in the buffer.
1597#[derive(Debug, Clone, PartialEq, Eq)]
1598struct TagSpan {
1599    kind: TagKind,
1600    name: String,
1601    /// Row index in the buffer.
1602    row: usize,
1603    /// Char-column range of the tag NAME (excluding `<`, `</`, attributes, `>`).
1604    name_start_col: usize,
1605    name_end_col: usize,
1606}
1607
1608/// Detect the tag containing `(row, col)` in `line`. Returns the tag kind
1609/// (Open / Close), its name, and the char-column range of that name.
1610/// Returns `None` when the cursor is not inside a tag-name region.
1611fn detect_tag_at_cursor(line: &str, row: usize, col: usize) -> Option<TagSpan> {
1612    let chars: Vec<char> = line.chars().collect();
1613    // Find the nearest `<` at or before the cursor column.
1614    let mut lt = None;
1615    let mut i = col.min(chars.len());
1616    while i > 0 {
1617        i -= 1;
1618        let c = chars[i];
1619        if c == '<' {
1620            lt = Some(i);
1621            break;
1622        }
1623        // Bail if we cross a `>` (we're outside any open tag).
1624        if c == '>' {
1625            return None;
1626        }
1627    }
1628    let lt = lt?;
1629    // Detect close tag (`</`) vs open (`<`).
1630    let (kind, name_start) = if chars.get(lt + 1) == Some(&'/') {
1631        (TagKind::Close, lt + 2)
1632    } else {
1633        (TagKind::Open, lt + 1)
1634    };
1635    // First char of the name must be a letter.
1636    let first = chars.get(name_start)?;
1637    if !first.is_ascii_alphabetic() {
1638        return None;
1639    }
1640    // Tag name = [A-Za-z][A-Za-z0-9-]*
1641    let mut name_end = name_start;
1642    while name_end < chars.len()
1643        && (chars[name_end].is_ascii_alphanumeric() || chars[name_end] == '-')
1644    {
1645        name_end += 1;
1646    }
1647    // Cursor must be inside the name range (inclusive of both ends so that
1648    // landing right after the name still resolves — vim Insert leaves the
1649    // cursor one past the last typed char).
1650    if col < name_start || col > name_end {
1651        return None;
1652    }
1653    let name: String = chars[name_start..name_end].iter().collect();
1654    Some(TagSpan {
1655        kind,
1656        name,
1657        row,
1658        name_start_col: name_start,
1659        name_end_col: name_end,
1660    })
1661}
1662
1663/// Scan the buffer to find the structural partner of `anchor` using a
1664/// depth counter. Names are intentionally NOT compared during the scan —
1665/// the anchor is the source of truth and the partner inherits its name.
1666/// Otherwise an in-flight rename (the whole point of this feature) would
1667/// look like a malformed pair and bail.
1668///
1669/// Forward scan from an opener: opens increment depth, closes decrement
1670/// depth. The close that brings depth back to zero is the partner.
1671/// Backward scan from a closer is symmetric (closes increment, opens
1672/// decrement).
1673///
1674/// Returns `None` when the buffer end is reached before depth hits zero
1675/// (orphan tag or malformed input).
1676fn find_matching_tag(buffer: &hjkl_buffer::Buffer, anchor: &TagSpan) -> Option<TagSpan> {
1677    let row_count = buffer.row_count();
1678    let scan_forward = anchor.kind == TagKind::Open;
1679    let row_iter: Box<dyn Iterator<Item = usize>> = if scan_forward {
1680        Box::new(anchor.row..row_count)
1681    } else {
1682        Box::new((0..=anchor.row).rev())
1683    };
1684    let push_kind = if scan_forward {
1685        TagKind::Open
1686    } else {
1687        TagKind::Close
1688    };
1689    let mut depth: usize = 1;
1690
1691    for r in row_iter {
1692        let line = buf_line(buffer, r)?;
1693        let chars: Vec<char> = line.chars().collect();
1694        let tags = scan_line_tags(&chars, r);
1695        let tags_iter: Box<dyn Iterator<Item = TagSpan>> = if scan_forward {
1696            Box::new(tags.into_iter())
1697        } else {
1698            Box::new(tags.into_iter().rev())
1699        };
1700        for tag in tags_iter {
1701            // Skip the anchor itself when we walk over its line.
1702            if r == anchor.row
1703                && tag.name_start_col == anchor.name_start_col
1704                && tag.kind == anchor.kind
1705            {
1706                continue;
1707            }
1708            // On the anchor's own row, gate by direction relative to anchor
1709            // so the scan only inspects tags AFTER the anchor (forward) or
1710            // BEFORE the anchor (backward).
1711            if r == anchor.row {
1712                if scan_forward && tag.name_start_col < anchor.name_start_col {
1713                    continue;
1714                }
1715                if !scan_forward && tag.name_start_col > anchor.name_start_col {
1716                    continue;
1717                }
1718            }
1719            if tag.kind == push_kind {
1720                depth += 1;
1721            } else {
1722                depth -= 1;
1723                if depth == 0 {
1724                    return Some(tag);
1725                }
1726            }
1727        }
1728    }
1729    None
1730}
1731
1732/// Collect all tag opens / closes on a single line in left-to-right order.
1733/// Skips comments (`<!-- ... -->`) and self-closing tags (`<br />`), and
1734/// excludes void HTML elements that don't form a pair.
1735fn scan_line_tags(chars: &[char], row: usize) -> Vec<TagSpan> {
1736    let mut out = Vec::new();
1737    let n = chars.len();
1738    let mut i = 0;
1739    while i < n {
1740        if chars[i] != '<' {
1741            i += 1;
1742            continue;
1743        }
1744        // `<!--` comment — skip to `-->`.
1745        if chars[i..].starts_with(&['<', '!', '-', '-']) {
1746            let mut j = i + 4;
1747            while j + 2 < n && !(chars[j] == '-' && chars[j + 1] == '-' && chars[j + 2] == '>') {
1748                j += 1;
1749            }
1750            i = (j + 3).min(n);
1751            continue;
1752        }
1753        let (kind, name_start) = if chars.get(i + 1) == Some(&'/') {
1754            (TagKind::Close, i + 2)
1755        } else {
1756            (TagKind::Open, i + 1)
1757        };
1758        // Validate name start.
1759        if chars
1760            .get(name_start)
1761            .is_none_or(|c| !c.is_ascii_alphabetic())
1762        {
1763            i += 1;
1764            continue;
1765        }
1766        let mut name_end = name_start;
1767        while name_end < n && (chars[name_end].is_ascii_alphanumeric() || chars[name_end] == '-') {
1768            name_end += 1;
1769        }
1770        // Find the closing `>` to know whether this tag is self-closing.
1771        let mut k = name_end;
1772        let mut self_closing = false;
1773        while k < n {
1774            if chars[k] == '>' {
1775                if k > name_end && chars[k - 1] == '/' {
1776                    self_closing = true;
1777                }
1778                break;
1779            }
1780            k += 1;
1781        }
1782        if k >= n {
1783            // Unterminated tag on this line — bail.
1784            break;
1785        }
1786        let name: String = chars[name_start..name_end].iter().collect();
1787        // Skip self-closing and void elements (no pair).
1788        if !(self_closing || kind == TagKind::Open && is_void_element(&name)) {
1789            out.push(TagSpan {
1790                kind,
1791                name,
1792                row,
1793                name_start_col: name_start,
1794                name_end_col: name_end,
1795            });
1796        }
1797        i = k + 1;
1798    }
1799    out
1800}
1801
1802/// If the cursor sits inside an HTML/XML tag name AND the paired tag's name
1803/// differs, rewrite the paired tag's name to match. Called from
1804/// `leave_insert_to_normal_bridge` so the magical sync fires exactly when
1805/// the user finishes editing.
1806pub(crate) fn sync_paired_tag_on_exit<H: crate::types::Host>(
1807    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1808) {
1809    if !is_html_filetype(&ed.settings.filetype) {
1810        return;
1811    }
1812    let (row, col) = ed.cursor();
1813    let line = match buf_line(&ed.buffer, row) {
1814        Some(l) => l,
1815        None => return,
1816    };
1817    let anchor = match detect_tag_at_cursor(&line, row, col) {
1818        Some(t) => t,
1819        None => return,
1820    };
1821    let partner = match find_matching_tag(&ed.buffer, &anchor) {
1822        Some(t) => t,
1823        None => return,
1824    };
1825    if partner.name == anchor.name {
1826        return;
1827    }
1828    // Rewrite the partner's name range with the anchor's name.
1829    use hjkl_buffer::{Edit, MotionKind, Position};
1830    let start = Position::new(partner.row, partner.name_start_col);
1831    let end = Position::new(partner.row, partner.name_end_col);
1832    ed.mutate_edit(Edit::DeleteRange {
1833        start,
1834        end,
1835        kind: MotionKind::Char,
1836    });
1837    ed.mutate_edit(Edit::InsertStr {
1838        at: start,
1839        text: anchor.name.clone(),
1840    });
1841    // Restore the user's cursor — mutate_edit may have moved it during the
1842    // partner-side rewrite when the partner is on a row before the cursor.
1843    buf_set_cursor_rc(&mut ed.buffer, row, col);
1844    ed.push_buffer_cursor_to_textarea();
1845}
1846
1847/// Resolve the HTML/XML tag-name pair under the cursor for matchparen-style
1848/// highlight (#243). Returns `[(row, name_start_col, name_end_col); 2]` for
1849/// the tag under the cursor and its structural partner, or `None` when the
1850/// cursor is not on a tag name or the tag is unpaired. Char-column ranges
1851/// (display), consistent with `motions::matching_bracket_pos`.
1852pub fn matching_tag_pair(
1853    buffer: &hjkl_buffer::Buffer,
1854    row: usize,
1855    col: usize,
1856) -> Option<[(usize, usize, usize); 2]> {
1857    let line = buf_line(buffer, row)?;
1858    let anchor = detect_tag_at_cursor(&line, row, col)?;
1859    let partner = find_matching_tag(buffer, &anchor)?;
1860    Some([
1861        (anchor.row, anchor.name_start_col, anchor.name_end_col),
1862        (partner.row, partner.name_start_col, partner.name_end_col),
1863    ])
1864}
1865
1866/// Void HTML elements that must never get an auto-close tag.
1867fn is_void_element(tag: &str) -> bool {
1868    matches!(
1869        tag.to_ascii_lowercase().as_str(),
1870        "area"
1871            | "base"
1872            | "br"
1873            | "col"
1874            | "embed"
1875            | "hr"
1876            | "img"
1877            | "input"
1878            | "link"
1879            | "meta"
1880            | "param"
1881            | "source"
1882            | "track"
1883            | "wbr"
1884    )
1885}
1886
1887/// Scan backward from `col` (exclusive) in `line` for a `<tagname…` opener.
1888///
1889/// Returns `Some(tag_name)` when:
1890/// - An opening `<` is found
1891/// - The tag name matches `[A-Za-z][A-Za-z0-9-]*`
1892/// - The tag is not self-closing (does not end with `/` before `>`)
1893/// - The tag is not a void element
1894///
1895/// Returns `None` otherwise (no opener, self-closing, void, or malformed).
1896fn scan_tag_opener(line: &str, col: usize) -> Option<String> {
1897    // col is where `>` was just inserted (the char is already in the line).
1898    // We look at the slice BEFORE the `>`.
1899    let before = if col > 0 { &line[..col] } else { return None };
1900
1901    // Walk backward to find the matching `<`.
1902    let lt_pos = before.rfind('<')?;
1903    let inner = &before[lt_pos + 1..]; // e.g. "div class=\"foo\""
1904
1905    // A `!` opener is a comment/doctype — skip.
1906    if inner.starts_with('!') {
1907        return None;
1908    }
1909    // Self-closing if the last non-space char before `>` was `/`.
1910    if inner.trim_end().ends_with('/') {
1911        return None;
1912    }
1913
1914    // Extract tag name: first token of `inner`.
1915    let tag: String = inner
1916        .chars()
1917        .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
1918        .collect();
1919    if tag.is_empty() {
1920        return None;
1921    }
1922    // First char must be a letter.
1923    if !tag
1924        .chars()
1925        .next()
1926        .map(|c| c.is_ascii_alphabetic())
1927        .unwrap_or(false)
1928    {
1929        return None;
1930    }
1931    if is_void_element(&tag) {
1932        return None;
1933    }
1934    Some(tag)
1935}
1936
1937/// Insert a single character at the cursor. Handles replace-mode overstrike
1938/// (when `InsertSession::reason` is `Replace`) and smart-indent dedent of
1939/// closing brackets (}/)]/). Also handles autopair insertion and skip-over.
1940/// Returns `true`.
1941pub(crate) fn insert_char_bridge<H: crate::types::Host>(
1942    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1943    ch: char,
1944) -> bool {
1945    use hjkl_buffer::{Edit, MotionKind, Position};
1946    ed.sync_buffer_content_from_textarea();
1947    let in_replace = matches!(
1948        ed.vim.insert_session.as_ref().map(|s| &s.reason),
1949        Some(InsertReason::Replace)
1950    );
1951
1952    // ── Abbreviation expansion (insert mode, non-replace) ────────────────────
1953    // A non-keyword char typed in insert mode can trigger expansion.
1954    // We check BEFORE inserting the character; if an abbrev matches, we delete
1955    // the lhs and insert the rhs, then continue to insert `ch` as normal.
1956    // `<C-v>` (literal-insert) must bypass this — callers that want literal
1957    // insertion should NOT call this bridge; they use insert_char_literal.
1958    if !in_replace && !ed.vim.abbrevs.is_empty() {
1959        let iskeyword = ed.settings.iskeyword.clone();
1960        if !is_keyword_char(ch, &iskeyword) {
1961            // Only non-keyword trigger chars fire abbreviation expansion.
1962            check_and_apply_abbrev(ed, AbbrevTrigger::NonKeyword(ch));
1963            // (we do NOT return early; continue to insert `ch` below)
1964        }
1965    }
1966    // Read cursor (after any abbreviation expansion that may have changed the buffer).
1967    let cursor = buf_cursor_pos(&ed.buffer);
1968    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
1969
1970    // ── Skip-over: if the typed char matches the top of the pending-closes
1971    // stack AND the char currently under the cursor IS that close char,
1972    // pop the stack and advance the cursor instead of inserting.
1973    //
1974    // We check the actual char in the buffer (not a stored col) so that
1975    // characters typed between the pair don't invalidate the skip — the
1976    // close char shifts right as the user types inside, but the buffer
1977    // char check always finds it correctly.
1978    if !in_replace
1979        && !ed.vim.pending_closes.is_empty()
1980        && let Some(&(pr, _pc, pch)) = ed.vim.pending_closes.last()
1981        && ch == pch
1982        && cursor.row == pr
1983    {
1984        let char_at_cursor =
1985            buf_line(&ed.buffer, cursor.row).and_then(|l| l.chars().nth(cursor.col));
1986        if char_at_cursor == Some(ch) {
1987            ed.vim.pending_closes.pop();
1988            // For `>` skip-over in HTML/XML: also run tag autoclose.
1989            let filetype = ed.settings.filetype.clone();
1990            let autoclose_tag = ed.settings.autoclose_tag;
1991            if ch == '>' && autoclose_tag && is_html_filetype(&filetype) {
1992                // Skip past the `>` that was auto-inserted.
1993                let new_col = cursor.col + 1;
1994                buf_set_cursor_rc(&mut ed.buffer, cursor.row, new_col);
1995                // Now check for tag autoclose on the line up to new_col.
1996                if let Some(line) = buf_line(&ed.buffer, cursor.row)
1997                    && let Some(tag) = scan_tag_opener(&line, new_col.saturating_sub(1))
1998                {
1999                    let close_tag = format!("</{tag}>");
2000                    let insert_pos = Position::new(cursor.row, new_col);
2001                    ed.mutate_edit(Edit::InsertStr {
2002                        at: insert_pos,
2003                        text: close_tag,
2004                    });
2005                    // Cursor stays at new_col (between > and </tag>).
2006                    buf_set_cursor_rc(&mut ed.buffer, cursor.row, new_col);
2007                }
2008            } else {
2009                buf_set_cursor_rc(&mut ed.buffer, cursor.row, cursor.col + 1);
2010            }
2011            ed.push_buffer_cursor_to_textarea();
2012            return true;
2013        }
2014    }
2015
2016    if in_replace && cursor.col < line_chars {
2017        // Replace mode: clear pending closes (edit outside the pair).
2018        ed.vim.pending_closes.clear();
2019        ed.mutate_edit(Edit::DeleteRange {
2020            start: cursor,
2021            end: Position::new(cursor.row, cursor.col + 1),
2022            kind: MotionKind::Char,
2023        });
2024        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
2025    } else if !try_dedent_close_bracket(ed, cursor, ch) {
2026        // Normal insert. Check autopair first.
2027        let autopair = ed.settings.autopair;
2028        let filetype = ed.settings.filetype.clone();
2029        let autoclose_tag = ed.settings.autoclose_tag;
2030
2031        let (prev_char, prev2_char) = {
2032            let line = buf_line(&ed.buffer, cursor.row).unwrap_or_default();
2033            let chars: Vec<char> = line.chars().collect();
2034            let p1 = if cursor.col > 0 {
2035                chars.get(cursor.col - 1).copied()
2036            } else {
2037                None
2038            };
2039            let p2 = if cursor.col > 1 {
2040                chars.get(cursor.col - 2).copied()
2041            } else {
2042                None
2043            };
2044            (p1, p2)
2045        };
2046
2047        if autopair {
2048            if let Some(close) = autopair_close_for(ch, &filetype, prev_char, prev2_char) {
2049                // Insert open char.
2050                ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
2051                // Insert close char immediately after the open char.
2052                // After inserting open at cursor, buffer cursor is at cursor.col+1.
2053                let after = Position::new(cursor.row, cursor.col + 1);
2054                ed.mutate_edit(Edit::InsertChar {
2055                    at: after,
2056                    ch: close,
2057                });
2058                // After inserting close, buffer cursor is at cursor.col+2.
2059                // We want cursor between open and close: cursor.col+1.
2060                let between_col = cursor.col + 1;
2061                buf_set_cursor_rc(&mut ed.buffer, cursor.row, between_col);
2062                // Record the close char for skip-over. We store the row and
2063                // the close char; col is not tracked precisely because chars
2064                // typed inside the pair shift the close right. The skip-over
2065                // logic checks the actual buffer char at cursor instead.
2066                ed.vim.pending_closes.push((cursor.row, between_col, close));
2067                ed.push_buffer_cursor_to_textarea();
2068                return true;
2069            }
2070
2071            // Tag autoclose: `>` in HTML/XML family (no prior `<` pair).
2072            // This fires when autopair did NOT match `>` (e.g. `>` was
2073            // typed directly, not via a skip-over of an auto-inserted `>`).
2074            if ch == '>' && autoclose_tag && is_html_filetype(&filetype) {
2075                ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
2076                let new_col = cursor.col + 1;
2077                // scan_tag_opener looks at the line up to (new_col-1), i.e.
2078                // the char just inserted is at index new_col-1.
2079                if let Some(line) = buf_line(&ed.buffer, cursor.row)
2080                    && let Some(tag) = scan_tag_opener(&line, new_col.saturating_sub(1))
2081                {
2082                    let close_tag = format!("</{tag}>");
2083                    let insert_pos = Position::new(cursor.row, new_col);
2084                    ed.mutate_edit(Edit::InsertStr {
2085                        at: insert_pos,
2086                        text: close_tag,
2087                    });
2088                    // Cursor stays at new_col (between `>` and `</tag>`).
2089                    buf_set_cursor_rc(&mut ed.buffer, cursor.row, new_col);
2090                }
2091                ed.push_buffer_cursor_to_textarea();
2092                return true;
2093            }
2094        }
2095
2096        // Plain insert — do not clear the pending-closes stack here.
2097        // The stack is cleared on cursor motion or mode change (Esc).
2098        // Clearing here would prevent skip-over from firing after the
2099        // user types content inside an auto-paired bracket.
2100        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
2101    }
2102    ed.push_buffer_cursor_to_textarea();
2103    true
2104}
2105
2106/// Insert a newline at the cursor, applying autoindent / smartindent and
2107/// optionally continuing a line comment when `formatoptions` has `r`.
2108/// Also handles open-pair-newline: Enter between `{|}` / `(|)` / `[|]`
2109/// produces an indented block with the close on its own line.
2110/// Returns `true`.
2111pub(crate) fn insert_newline_bridge<H: crate::types::Host>(
2112    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2113) -> bool {
2114    use hjkl_buffer::Edit;
2115    ed.sync_buffer_content_from_textarea();
2116
2117    // ── Abbreviation expansion on CR ─────────────────────────────────────────
2118    // CR triggers expansion for full-id / end-id / non-id abbreviations.
2119    // We expand BEFORE the newline is inserted; CR is then inserted as normal.
2120    if !ed.vim.abbrevs.is_empty() {
2121        check_and_apply_abbrev(ed, AbbrevTrigger::Cr);
2122    }
2123
2124    let cursor = buf_cursor_pos(&ed.buffer);
2125    let prev_line = buf_line(&ed.buffer, cursor.row)
2126        .unwrap_or_default()
2127        .to_string();
2128
2129    // Open-pair-newline: if autopair is on and the cursor is between a
2130    // matching open/close bracket pair, split into two newlines so the
2131    // close ends up on its own dedented line.
2132    if ed.settings.autopair && !ed.vim.pending_closes.is_empty() {
2133        // Check: char before cursor is an open bracket AND char at cursor
2134        // is the matching close bracket (from our pending-closes stack).
2135        let prev_char = if cursor.col > 0 {
2136            prev_line.chars().nth(cursor.col - 1)
2137        } else {
2138            None
2139        };
2140        let next_char = prev_line.chars().nth(cursor.col);
2141        let is_open_pair = matches!(
2142            (prev_char, next_char),
2143            (Some('{'), Some('}')) | (Some('('), Some(')')) | (Some('['), Some(']'))
2144        );
2145        if is_open_pair {
2146            // The pending-closes stack refers to the close char at cursor.col.
2147            // We clear it because the newline expansion moves the close.
2148            ed.vim.pending_closes.clear();
2149            // Compute indents: inner gets one extra unit, close gets base.
2150            let base_indent: String = prev_line
2151                .chars()
2152                .take_while(|c| *c == ' ' || *c == '\t')
2153                .collect();
2154            let inner_indent = if ed.settings.expandtab {
2155                let unit = if ed.settings.softtabstop > 0 {
2156                    ed.settings.softtabstop
2157                } else {
2158                    ed.settings.shiftwidth
2159                };
2160                format!("{base_indent}{}", " ".repeat(unit))
2161            } else {
2162                format!("{base_indent}\t")
2163            };
2164            // Insert: \n<inner_indent>\n<base_indent>
2165            // Then cursor lands after the first \n (inside the block).
2166            let text = format!("\n{inner_indent}\n{base_indent}");
2167            ed.mutate_edit(Edit::InsertStr { at: cursor, text });
2168            // Move cursor to end of first new line (inner_indent line).
2169            let new_row = cursor.row + 1;
2170            let new_col = inner_indent.len();
2171            buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
2172            ed.push_buffer_cursor_to_textarea();
2173            return true;
2174        }
2175    }
2176
2177    // Code-fence expansion: line content is ` ``` ` (3+ backticks) followed
2178    // by a non-empty language tag, cursor sits at end of line → insert the
2179    // matching closing fence on the line below and park the cursor on a
2180    // blank middle line. Matches the open-pair-newline shape but for
2181    // markdown / doc-comment code blocks. Gated on a language tag because
2182    // a bare ` ``` ` could just as easily be a closing fence — we'd need
2183    // full document parity tracking to handle that safely, which v1
2184    // doesn't have.
2185    if ed.settings.autopair
2186        && let Some(fence) = detect_code_fence_opener(&prev_line, cursor.col)
2187    {
2188        ed.vim.pending_closes.clear();
2189        let base_indent: String = prev_line
2190            .chars()
2191            .take_while(|c| *c == ' ' || *c == '\t')
2192            .collect();
2193        let text = format!("\n{base_indent}\n{base_indent}{fence}");
2194        ed.mutate_edit(Edit::InsertStr { at: cursor, text });
2195        let new_row = cursor.row + 1;
2196        let new_col = base_indent.chars().count();
2197        buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
2198        ed.push_buffer_cursor_to_textarea();
2199        return true;
2200    }
2201
2202    // formatoptions `r`: continue comment on Enter in insert mode.
2203    let comment_cont = if ed.settings.formatoptions.contains('r') {
2204        continue_comment(&ed.buffer, &ed.settings, cursor.row)
2205    } else {
2206        None
2207    };
2208
2209    // Any Enter clears the pending-closes stack (cursor moved off the pair).
2210    ed.vim.pending_closes.clear();
2211
2212    let text = if let Some(cont) = comment_cont {
2213        // Comment continuation overrides autoindent: the indent is already
2214        // baked into the continuation prefix.
2215        format!("\n{cont}")
2216    } else {
2217        let indent = compute_enter_indent(&ed.settings, &prev_line);
2218        format!("\n{indent}")
2219    };
2220    ed.mutate_edit(Edit::InsertStr { at: cursor, text });
2221    ed.push_buffer_cursor_to_textarea();
2222    true
2223}
2224
2225/// Insert a tab character (or spaces up to the next softtabstop boundary when
2226/// `expandtab` is set). Returns `true`.
2227pub(crate) fn insert_tab_bridge<H: crate::types::Host>(
2228    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2229) -> bool {
2230    use hjkl_buffer::Edit;
2231    ed.sync_buffer_content_from_textarea();
2232    let cursor = buf_cursor_pos(&ed.buffer);
2233    if ed.settings.expandtab {
2234        let sts = ed.settings.softtabstop;
2235        let n = if sts > 0 {
2236            sts - (cursor.col % sts)
2237        } else {
2238            ed.settings.tabstop.max(1)
2239        };
2240        ed.mutate_edit(Edit::InsertStr {
2241            at: cursor,
2242            text: " ".repeat(n),
2243        });
2244    } else {
2245        ed.mutate_edit(Edit::InsertChar {
2246            at: cursor,
2247            ch: '\t',
2248        });
2249    }
2250    ed.push_buffer_cursor_to_textarea();
2251    true
2252}
2253
2254/// Delete the character before the cursor (vim Backspace / `^H`). With
2255/// `softtabstop` active, deletes the entire soft-tab run at an aligned
2256/// boundary. Joins with the previous line when at column 0.
2257///
2258/// **Comment-continuation backspace**: when the current line's entire content
2259/// is the auto-inserted comment prefix (e.g. `// ` with nothing after it),
2260/// a single Backspace removes the whole prefix in one stroke — vim parity.
2261///
2262/// Returns `true` when something was deleted, `false` at the very start of the
2263/// buffer.
2264pub(crate) fn insert_backspace_bridge<H: crate::types::Host>(
2265    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2266) -> bool {
2267    use hjkl_buffer::{Edit, MotionKind, Position};
2268    ed.sync_buffer_content_from_textarea();
2269    let cursor = buf_cursor_pos(&ed.buffer);
2270
2271    // Comment-continuation backspace: if the line is just the prefix (with no
2272    // user content after it), delete the whole prefix in one stroke.
2273    if cursor.col > 0 {
2274        let line = buf_line(&ed.buffer, cursor.row).unwrap_or_default();
2275        if let Some((indent, prefix)) = detect_comment_on_line(&ed.settings.filetype, &line) {
2276            let full_prefix = format!("{indent}{prefix}");
2277            // The cursor must be at the end of (or within) the prefix with no
2278            // additional content after — i.e. the line equals the prefix exactly.
2279            let line_trimmed = line.trim_end_matches(' ');
2280            let prefix_trimmed = full_prefix.trim_end_matches(' ');
2281            if line_trimmed == prefix_trimmed && cursor.col == full_prefix.chars().count() {
2282                // Delete everything from col 0 to cursor.
2283                ed.mutate_edit(Edit::DeleteRange {
2284                    start: Position::new(cursor.row, 0),
2285                    end: cursor,
2286                    kind: MotionKind::Char,
2287                });
2288                ed.push_buffer_cursor_to_textarea();
2289                return true;
2290            }
2291        }
2292    }
2293
2294    let sts = ed.settings.softtabstop;
2295    if sts > 0 && cursor.col >= sts && cursor.col.is_multiple_of(sts) {
2296        let line = buf_line(&ed.buffer, cursor.row).unwrap_or_default();
2297        let chars: Vec<char> = line.chars().collect();
2298        let run_start = cursor.col - sts;
2299        if (run_start..cursor.col).all(|i| chars.get(i).copied() == Some(' ')) {
2300            ed.mutate_edit(Edit::DeleteRange {
2301                start: Position::new(cursor.row, run_start),
2302                end: cursor,
2303                kind: MotionKind::Char,
2304            });
2305            ed.push_buffer_cursor_to_textarea();
2306            return true;
2307        }
2308    }
2309    let result = if cursor.col > 0 {
2310        ed.mutate_edit(Edit::DeleteRange {
2311            start: Position::new(cursor.row, cursor.col - 1),
2312            end: cursor,
2313            kind: MotionKind::Char,
2314        });
2315        true
2316    } else if cursor.row > 0 {
2317        let prev_row = cursor.row - 1;
2318        let prev_chars = buf_line_chars(&ed.buffer, prev_row);
2319        ed.mutate_edit(Edit::JoinLines {
2320            row: prev_row,
2321            count: 1,
2322            with_space: false,
2323        });
2324        buf_set_cursor_rc(&mut ed.buffer, prev_row, prev_chars);
2325        true
2326    } else {
2327        false
2328    };
2329    ed.push_buffer_cursor_to_textarea();
2330    result
2331}
2332
2333/// Delete the character under the cursor (vim `Delete`). Joins with the
2334/// next line when at end-of-line. Returns `true` when something was deleted.
2335pub(crate) fn insert_delete_bridge<H: crate::types::Host>(
2336    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2337) -> bool {
2338    use hjkl_buffer::{Edit, MotionKind, Position};
2339    ed.sync_buffer_content_from_textarea();
2340    let cursor = buf_cursor_pos(&ed.buffer);
2341    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
2342    let result = if cursor.col < line_chars {
2343        ed.mutate_edit(Edit::DeleteRange {
2344            start: cursor,
2345            end: Position::new(cursor.row, cursor.col + 1),
2346            kind: MotionKind::Char,
2347        });
2348        buf_set_cursor_pos(&mut ed.buffer, cursor);
2349        true
2350    } else if cursor.row + 1 < buf_row_count(&ed.buffer) {
2351        ed.mutate_edit(Edit::JoinLines {
2352            row: cursor.row,
2353            count: 1,
2354            with_space: false,
2355        });
2356        buf_set_cursor_pos(&mut ed.buffer, cursor);
2357        true
2358    } else {
2359        false
2360    };
2361    ed.push_buffer_cursor_to_textarea();
2362    result
2363}
2364
2365/// Direction for insert-mode arrow movement.
2366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2367pub enum InsertDir {
2368    Left,
2369    Right,
2370    Up,
2371    Down,
2372}
2373
2374/// Move the cursor one step in `dir`, breaking the undo group per
2375/// `undo_break_on_motion`. Clears the autopair pending-closes stack (cursor
2376/// moved off the pair). Returns `false` (no mutation).
2377pub(crate) fn insert_arrow_bridge<H: crate::types::Host>(
2378    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2379    dir: InsertDir,
2380) -> bool {
2381    ed.sync_buffer_content_from_textarea();
2382    ed.vim.pending_closes.clear();
2383    match dir {
2384        InsertDir::Left => {
2385            crate::motions::move_left(&mut ed.buffer, 1);
2386        }
2387        InsertDir::Right => {
2388            crate::motions::move_right_to_end(&mut ed.buffer, 1);
2389        }
2390        InsertDir::Up => {
2391            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2392            crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
2393        }
2394        InsertDir::Down => {
2395            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2396            crate::motions::move_down(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
2397        }
2398    }
2399    break_undo_group_in_insert(ed);
2400    ed.push_buffer_cursor_to_textarea();
2401    false
2402}
2403
2404/// Move the cursor to the start of the current line, breaking the undo group.
2405/// Clears the autopair pending-closes stack. Returns `false` (no mutation).
2406pub(crate) fn insert_home_bridge<H: crate::types::Host>(
2407    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2408) -> bool {
2409    ed.sync_buffer_content_from_textarea();
2410    ed.vim.pending_closes.clear();
2411    crate::motions::move_line_start(&mut ed.buffer);
2412    break_undo_group_in_insert(ed);
2413    ed.push_buffer_cursor_to_textarea();
2414    false
2415}
2416
2417/// Move the cursor to the end of the current line, breaking the undo group.
2418/// Clears the autopair pending-closes stack. Returns `false` (no mutation).
2419pub(crate) fn insert_end_bridge<H: crate::types::Host>(
2420    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2421) -> bool {
2422    ed.sync_buffer_content_from_textarea();
2423    ed.vim.pending_closes.clear();
2424    crate::motions::move_line_end(&mut ed.buffer);
2425    break_undo_group_in_insert(ed);
2426    ed.push_buffer_cursor_to_textarea();
2427    false
2428}
2429
2430/// Scroll up one full viewport height, moving the cursor with it.
2431/// Breaks the undo group. Returns `false` (no mutation).
2432pub(crate) fn insert_pageup_bridge<H: crate::types::Host>(
2433    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2434    viewport_h: u16,
2435) -> bool {
2436    let rows = viewport_h.saturating_sub(2).max(1) as isize;
2437    scroll_cursor_rows(ed, -rows);
2438    false
2439}
2440
2441/// Scroll down one full viewport height, moving the cursor with it.
2442/// Breaks the undo group. Returns `false` (no mutation).
2443pub(crate) fn insert_pagedown_bridge<H: crate::types::Host>(
2444    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2445    viewport_h: u16,
2446) -> bool {
2447    let rows = viewport_h.saturating_sub(2).max(1) as isize;
2448    scroll_cursor_rows(ed, rows);
2449    false
2450}
2451
2452/// Delete from the cursor back to the start of the previous word (`Ctrl-W`).
2453/// At col 0, joins with the previous line (vim semantics). Returns `true`
2454/// when something was deleted.
2455pub(crate) fn insert_ctrl_w_bridge<H: crate::types::Host>(
2456    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2457) -> bool {
2458    use hjkl_buffer::{Edit, MotionKind};
2459    ed.sync_buffer_content_from_textarea();
2460    let cursor = buf_cursor_pos(&ed.buffer);
2461    if cursor.row == 0 && cursor.col == 0 {
2462        return true;
2463    }
2464    crate::motions::move_word_back(&mut ed.buffer, false, 1, &ed.settings.iskeyword);
2465    let word_start = buf_cursor_pos(&ed.buffer);
2466    if word_start == cursor {
2467        return true;
2468    }
2469    buf_set_cursor_pos(&mut ed.buffer, cursor);
2470    ed.mutate_edit(Edit::DeleteRange {
2471        start: word_start,
2472        end: cursor,
2473        kind: MotionKind::Char,
2474    });
2475    ed.push_buffer_cursor_to_textarea();
2476    true
2477}
2478
2479/// Delete from the cursor back to the start of the current line (`Ctrl-U`).
2480/// No-op when already at column 0. Returns `true` when something was deleted.
2481pub(crate) fn insert_ctrl_u_bridge<H: crate::types::Host>(
2482    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2483) -> bool {
2484    use hjkl_buffer::{Edit, MotionKind, Position};
2485    ed.sync_buffer_content_from_textarea();
2486    let cursor = buf_cursor_pos(&ed.buffer);
2487    if cursor.col > 0 {
2488        ed.mutate_edit(Edit::DeleteRange {
2489            start: Position::new(cursor.row, 0),
2490            end: cursor,
2491            kind: MotionKind::Char,
2492        });
2493        ed.push_buffer_cursor_to_textarea();
2494    }
2495    true
2496}
2497
2498/// Delete one character backwards (`Ctrl-H`) — alias for Backspace in insert
2499/// mode. Joins with the previous line when at col 0. Returns `true` when
2500/// something was deleted.
2501pub(crate) fn insert_ctrl_h_bridge<H: crate::types::Host>(
2502    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2503) -> bool {
2504    use hjkl_buffer::{Edit, MotionKind, Position};
2505    ed.sync_buffer_content_from_textarea();
2506    let cursor = buf_cursor_pos(&ed.buffer);
2507    if cursor.col > 0 {
2508        ed.mutate_edit(Edit::DeleteRange {
2509            start: Position::new(cursor.row, cursor.col - 1),
2510            end: cursor,
2511            kind: MotionKind::Char,
2512        });
2513    } else if cursor.row > 0 {
2514        let prev_row = cursor.row - 1;
2515        let prev_chars = buf_line_chars(&ed.buffer, prev_row);
2516        ed.mutate_edit(Edit::JoinLines {
2517            row: prev_row,
2518            count: 1,
2519            with_space: false,
2520        });
2521        buf_set_cursor_rc(&mut ed.buffer, prev_row, prev_chars);
2522    }
2523    ed.push_buffer_cursor_to_textarea();
2524    true
2525}
2526
2527/// Indent the current line by one `shiftwidth` and shift the cursor right by
2528/// the same amount (`Ctrl-T`). Returns `true`.
2529pub(crate) fn insert_ctrl_t_bridge<H: crate::types::Host>(
2530    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2531) -> bool {
2532    let (row, col) = ed.cursor();
2533    let sw = ed.settings().shiftwidth;
2534    indent_rows(ed, row, row, 1);
2535    ed.jump_cursor(row, col + sw);
2536    true
2537}
2538
2539/// Outdent the current line by up to one `shiftwidth` and shift the cursor
2540/// left by the amount stripped (`Ctrl-D`). Returns `true`.
2541pub(crate) fn insert_ctrl_d_bridge<H: crate::types::Host>(
2542    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2543) -> bool {
2544    let (row, col) = ed.cursor();
2545    let before_len = buf_line_bytes(&ed.buffer, row);
2546    outdent_rows(ed, row, row, 1);
2547    let after_len = buf_line_bytes(&ed.buffer, row);
2548    let stripped = before_len.saturating_sub(after_len);
2549    let new_col = col.saturating_sub(stripped);
2550    ed.jump_cursor(row, new_col);
2551    true
2552}
2553
2554/// Enter "one-shot normal" mode (`Ctrl-O`): suspend insert for the next
2555/// complete normal-mode command, then return to insert. Returns `false`
2556/// (no buffer mutation — only mode state changes).
2557pub(crate) fn insert_ctrl_o_bridge<H: crate::types::Host>(
2558    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2559) -> bool {
2560    ed.vim.one_shot_normal = true;
2561    ed.vim.mode = Mode::Normal;
2562    // Phase 6.3: keep current_mode in sync for callers that bypass step().
2563    ed.vim.current_mode = crate::VimMode::Normal;
2564    false
2565}
2566
2567/// Arm the register-paste selector (`Ctrl-R`): the next typed character
2568/// names the register whose text will be inserted inline. Returns `false`
2569/// (no buffer mutation yet — mutation happens when the register char arrives).
2570pub(crate) fn insert_ctrl_r_bridge<H: crate::types::Host>(
2571    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2572) -> bool {
2573    ed.vim.insert_pending_register = true;
2574    false
2575}
2576
2577/// Paste the contents of `reg` at the cursor (the body of `Ctrl-R {reg}`).
2578/// Unknown or empty registers are a no-op. Returns `true` when text was
2579/// inserted.
2580pub(crate) fn insert_paste_register_bridge<H: crate::types::Host>(
2581    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2582    reg: char,
2583) -> bool {
2584    insert_register_text(ed, reg);
2585    // insert_register_text already calls mark_content_dirty internally;
2586    // return true to signal that the session row window should be widened.
2587    true
2588}
2589
2590/// Exit insert mode to Normal: finish the insert session, step the cursor one
2591/// cell left (vim convention), record the `gi` target, and update the sticky
2592/// column. Clears the autopair pending-closes stack. Returns `true` (always
2593/// consumed — even if no buffer mutation, the mode change itself is a
2594/// meaningful step).
2595pub(crate) fn leave_insert_to_normal_bridge<H: crate::types::Host>(
2596    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2597) -> bool {
2598    ed.vim.pending_closes.clear();
2599
2600    // ── Abbreviation expansion on Esc ────────────────────────────────────────
2601    // Esc triggers expansion for all abbreviation types.
2602    if !ed.vim.abbrevs.is_empty() {
2603        check_and_apply_abbrev(ed, AbbrevTrigger::Esc);
2604    }
2605
2606    finish_insert_session(ed);
2607    // Paired-tag auto-rename (issue #182). Must run BEFORE the cursor moves
2608    // left (the move-left is vim's "leave-insert cursor adjustment"; the
2609    // sync needs the post-insert cursor position to detect the tag name).
2610    sync_paired_tag_on_exit(ed);
2611    ed.vim.mode = Mode::Normal;
2612    // Phase 6.3: keep current_mode in sync for callers that bypass step().
2613    ed.vim.current_mode = crate::VimMode::Normal;
2614    let col = ed.cursor().1;
2615    ed.vim.last_insert_pos = Some(ed.cursor());
2616    if col > 0 {
2617        crate::motions::move_left(&mut ed.buffer, 1);
2618        ed.push_buffer_cursor_to_textarea();
2619    }
2620    ed.sticky_col = Some(ed.cursor().1);
2621    true
2622}
2623
2624// ─── Phase 6.2: normal-mode primitive bridges ──────────────────────────────
2625
2626/// Scroll direction for `scroll_full_page`, `scroll_half_page`, and
2627/// `scroll_line` controller methods.
2628#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2629pub enum ScrollDir {
2630    /// Move forward / downward (toward end of buffer).
2631    Down,
2632    /// Move backward / upward (toward start of buffer).
2633    Up,
2634}
2635
2636// ── Insert-mode entry bridges ──────────────────────────────────────────────
2637
2638/// `i` — begin Insert at the cursor. `count` is stored in the session for
2639/// insert-exit replay. Returns `true`.
2640pub(crate) fn enter_insert_i_bridge<H: crate::types::Host>(
2641    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2642    count: usize,
2643) {
2644    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::I));
2645}
2646
2647/// `I` — move to first non-blank then begin Insert. `count` stored for replay.
2648pub(crate) fn enter_insert_shift_i_bridge<H: crate::types::Host>(
2649    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2650    count: usize,
2651) {
2652    move_first_non_whitespace(ed);
2653    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftI));
2654}
2655
2656/// `a` — advance past the cursor char then begin Insert. `count` for replay.
2657pub(crate) fn enter_insert_a_bridge<H: crate::types::Host>(
2658    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2659    count: usize,
2660) {
2661    crate::motions::move_right_to_end(&mut ed.buffer, 1);
2662    ed.push_buffer_cursor_to_textarea();
2663    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::A));
2664}
2665
2666/// `A` — move to end-of-line then begin Insert. `count` for replay.
2667pub(crate) fn enter_insert_shift_a_bridge<H: crate::types::Host>(
2668    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2669    count: usize,
2670) {
2671    crate::motions::move_line_end(&mut ed.buffer);
2672    crate::motions::move_right_to_end(&mut ed.buffer, 1);
2673    ed.push_buffer_cursor_to_textarea();
2674    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftA));
2675}
2676
2677/// `o` — open a new line below the cursor and begin Insert.
2678/// When `formatoptions` has `o` and the current line is a comment, the
2679/// continuation prefix is inserted automatically.
2680pub(crate) fn open_line_below_bridge<H: crate::types::Host>(
2681    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2682    count: usize,
2683) {
2684    use hjkl_buffer::{Edit, Position};
2685    ed.push_undo();
2686    begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: false });
2687    ed.sync_buffer_content_from_textarea();
2688    let row = buf_cursor_pos(&ed.buffer).row;
2689    let line_chars = buf_line_chars(&ed.buffer, row);
2690    let prev_line = buf_line(&ed.buffer, row).unwrap_or_default();
2691
2692    // formatoptions `o`: continue comment on open-below.
2693    let comment_cont = if ed.settings.formatoptions.contains('o') {
2694        continue_comment(&ed.buffer, &ed.settings, row)
2695    } else {
2696        None
2697    };
2698
2699    let suffix = if let Some(cont) = comment_cont {
2700        format!("\n{cont}")
2701    } else {
2702        let indent = compute_enter_indent(&ed.settings, &prev_line);
2703        format!("\n{indent}")
2704    };
2705    ed.mutate_edit(Edit::InsertStr {
2706        at: Position::new(row, line_chars),
2707        text: suffix,
2708    });
2709    ed.push_buffer_cursor_to_textarea();
2710}
2711
2712/// `O` — open a new line above the cursor and begin Insert.
2713/// When `formatoptions` has `o` and the current line is a comment, the
2714/// continuation prefix is inserted automatically on the new line above.
2715pub(crate) fn open_line_above_bridge<H: crate::types::Host>(
2716    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2717    count: usize,
2718) {
2719    use hjkl_buffer::{Edit, Position};
2720    ed.push_undo();
2721    begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: true });
2722    ed.sync_buffer_content_from_textarea();
2723    let row = buf_cursor_pos(&ed.buffer).row;
2724
2725    // formatoptions `o`: continue comment on open-above (current line drives).
2726    let comment_cont = if ed.settings.formatoptions.contains('o') {
2727        continue_comment(&ed.buffer, &ed.settings, row)
2728    } else {
2729        None
2730    };
2731
2732    // `new_line_content` is the text of the new line (without the trailing `\n`).
2733    // Used to position the cursor at the end of that content after the move.
2734    let (insert_text, new_line_content) = if let Some(cont) = comment_cont {
2735        let content = cont.clone();
2736        (format!("{cont}\n"), content)
2737    } else {
2738        // vim `O` autoindent copies the CURRENT line's indent (the line the
2739        // cursor sits on, which becomes the line *below* the new one), NOT the
2740        // line above. Using the line above wrongly inherits a deeper child's
2741        // indent when the cursor is on a shallower line (e.g. explorer tree:
2742        // `O` on a dir whose preceding row is its own nested child).
2743        let cur = buf_line(&ed.buffer, row).unwrap_or_default();
2744        let indent = compute_enter_indent(&ed.settings, &cur);
2745        let content = indent.clone();
2746        (format!("{indent}\n"), content)
2747    };
2748    ed.mutate_edit(Edit::InsertStr {
2749        at: Position::new(row, 0),
2750        text: insert_text,
2751    });
2752    let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2753    crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
2754    let new_row = buf_cursor_pos(&ed.buffer).row;
2755    buf_set_cursor_rc(&mut ed.buffer, new_row, new_line_content.chars().count());
2756    ed.push_buffer_cursor_to_textarea();
2757}
2758
2759/// `R` — enter Replace mode (overstrike). `count` stored for replay.
2760pub(crate) fn enter_replace_mode_bridge<H: crate::types::Host>(
2761    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2762    count: usize,
2763) {
2764    // Guard delegated to begin_insert which already checks modifiable/Blame.
2765    begin_insert(ed, count.max(1), InsertReason::Replace);
2766}
2767
2768// ── Char / line ops ────────────────────────────────────────────────────────
2769
2770/// `x` — delete `count` chars forward from the cursor, writing to the unnamed
2771/// register. Records `LastChange::CharDel` for dot-repeat.
2772pub(crate) fn delete_char_forward_bridge<H: crate::types::Host>(
2773    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2774    count: usize,
2775) {
2776    do_char_delete(ed, true, count.max(1));
2777    if !ed.vim.replaying {
2778        ed.vim.last_change = Some(LastChange::CharDel {
2779            forward: true,
2780            count: count.max(1),
2781        });
2782    }
2783}
2784
2785/// `X` — delete `count` chars backward from the cursor, writing to the unnamed
2786/// register. Records `LastChange::CharDel` for dot-repeat.
2787pub(crate) fn delete_char_backward_bridge<H: crate::types::Host>(
2788    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2789    count: usize,
2790) {
2791    do_char_delete(ed, false, count.max(1));
2792    if !ed.vim.replaying {
2793        ed.vim.last_change = Some(LastChange::CharDel {
2794            forward: false,
2795            count: count.max(1),
2796        });
2797    }
2798}
2799
2800/// `s` — substitute `count` chars (delete then enter Insert). Equivalent to
2801/// `cl`. Records `LastChange::OpMotion` for dot-repeat.
2802pub(crate) fn substitute_char_bridge<H: crate::types::Host>(
2803    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2804    count: usize,
2805) {
2806    use hjkl_buffer::{Edit, MotionKind, Position};
2807    ed.push_undo();
2808    ed.sync_buffer_content_from_textarea();
2809    for _ in 0..count.max(1) {
2810        let cursor = buf_cursor_pos(&ed.buffer);
2811        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
2812        if cursor.col >= line_chars {
2813            break;
2814        }
2815        ed.mutate_edit(Edit::DeleteRange {
2816            start: cursor,
2817            end: Position::new(cursor.row, cursor.col + 1),
2818            kind: MotionKind::Char,
2819        });
2820    }
2821    ed.push_buffer_cursor_to_textarea();
2822    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
2823    if !ed.vim.replaying {
2824        ed.vim.last_change = Some(LastChange::OpMotion {
2825            op: Operator::Change,
2826            motion: Motion::Right,
2827            count: count.max(1),
2828            inserted: None,
2829        });
2830    }
2831}
2832
2833/// `S` — substitute the whole line (delete line contents then enter Insert).
2834/// Equivalent to `cc`. Records `LastChange::LineOp` for dot-repeat.
2835pub(crate) fn substitute_line_bridge<H: crate::types::Host>(
2836    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2837    count: usize,
2838) {
2839    execute_line_op(ed, Operator::Change, count.max(1));
2840    if !ed.vim.replaying {
2841        ed.vim.last_change = Some(LastChange::LineOp {
2842            op: Operator::Change,
2843            count: count.max(1),
2844            inserted: None,
2845        });
2846    }
2847}
2848
2849/// `D` — delete from the cursor to end-of-line, writing to the unnamed
2850/// register. Cursor parks on the new last char. Records for dot-repeat.
2851pub(crate) fn delete_to_eol_bridge<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2852    ed.push_undo();
2853    delete_to_eol(ed);
2854    crate::motions::move_left(&mut ed.buffer, 1);
2855    ed.push_buffer_cursor_to_textarea();
2856    if !ed.vim.replaying {
2857        ed.vim.last_change = Some(LastChange::DeleteToEol { inserted: None });
2858    }
2859}
2860
2861/// `C` — change from the cursor to end-of-line (delete then enter Insert).
2862/// Equivalent to `c$`. Shares the delete path with `D`.
2863pub(crate) fn change_to_eol_bridge<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2864    ed.push_undo();
2865    delete_to_eol(ed);
2866    begin_insert_noundo(ed, 1, InsertReason::DeleteToEol);
2867}
2868
2869/// `Y` — yank from the cursor to end-of-line (same as `y$` in Vim 8 default).
2870pub(crate) fn yank_to_eol_bridge<H: crate::types::Host>(
2871    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2872    count: usize,
2873) {
2874    apply_op_with_motion(ed, Operator::Yank, &Motion::LineEnd, count.max(1));
2875}
2876
2877/// `J` — join `count` lines (default 2) onto the current one, inserting a
2878/// single space between each pair (vim semantics). Records for dot-repeat.
2879pub(crate) fn join_line_bridge<H: crate::types::Host>(
2880    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2881    count: usize,
2882) {
2883    // vim `[count]J` joins `count` lines together — i.e. `count - 1` joins.
2884    // Bare `J` (and `1J`) join the current line with the one below (1 join).
2885    let joins = count.max(2) - 1;
2886    for _ in 0..joins {
2887        ed.push_undo();
2888        join_line(ed);
2889    }
2890    if !ed.vim.replaying {
2891        ed.vim.last_change = Some(LastChange::JoinLine { count: joins });
2892    }
2893}
2894
2895/// `~` — toggle the case of `count` chars from the cursor, advancing right.
2896/// Records `LastChange::ToggleCase` for dot-repeat.
2897pub(crate) fn toggle_case_at_cursor_bridge<H: crate::types::Host>(
2898    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2899    count: usize,
2900) {
2901    for _ in 0..count.max(1) {
2902        ed.push_undo();
2903        toggle_case_at_cursor(ed);
2904    }
2905    if !ed.vim.replaying {
2906        ed.vim.last_change = Some(LastChange::ToggleCase {
2907            count: count.max(1),
2908        });
2909    }
2910}
2911
2912/// `p` — paste the unnamed register (or `"reg` register) after the cursor.
2913/// Linewise yanks open a new line below; charwise pastes inline.
2914/// Records `LastChange::Paste` for dot-repeat.
2915pub(crate) fn paste_after_bridge<H: crate::types::Host>(
2916    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2917    count: usize,
2918) {
2919    paste_bridge(ed, false, count, false, false);
2920}
2921
2922/// `P` — paste the unnamed register (or `"reg` register) before the cursor.
2923/// Linewise yanks open a new line above; charwise pastes inline.
2924/// Records `LastChange::Paste` for dot-repeat.
2925pub(crate) fn paste_before_bridge<H: crate::types::Host>(
2926    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2927    count: usize,
2928) {
2929    paste_bridge(ed, true, count, false, false);
2930}
2931
2932/// Shared paste entry for `p`/`P`, `gp`/`gP` (`cursor_after`), and
2933/// `]p`/`[p` (`reindent`). Records `LastChange::Paste` for dot-repeat.
2934pub(crate) fn paste_bridge<H: crate::types::Host>(
2935    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2936    before: bool,
2937    count: usize,
2938    cursor_after: bool,
2939    reindent: bool,
2940) {
2941    do_paste(ed, before, count.max(1), cursor_after, reindent);
2942    if !ed.vim.replaying {
2943        ed.vim.last_change = Some(LastChange::Paste {
2944            before,
2945            count: count.max(1),
2946            cursor_after,
2947            reindent,
2948        });
2949    }
2950}
2951
2952// ── Jump bridges ───────────────────────────────────────────────────────────
2953
2954/// `<C-o>` — jump back `count` entries in the jumplist, saving the current
2955/// position on the forward stack so `<C-i>` can return.
2956pub(crate) fn jump_back_bridge<H: crate::types::Host>(
2957    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2958    count: usize,
2959) {
2960    for _ in 0..count.max(1) {
2961        jump_back(ed);
2962    }
2963}
2964
2965/// `<C-i>` / `Tab` — redo `count` jumps on the forward stack, saving the
2966/// current position on the backward stack.
2967pub(crate) fn jump_forward_bridge<H: crate::types::Host>(
2968    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2969    count: usize,
2970) {
2971    for _ in 0..count.max(1) {
2972        jump_forward(ed);
2973    }
2974}
2975
2976// ── Scroll bridges ─────────────────────────────────────────────────────────
2977
2978/// `<C-f>` / `<C-b>` — scroll the cursor by one full viewport height
2979/// (`h - 2` rows to preserve two-line overlap). `count` multiplies.
2980pub(crate) fn scroll_full_page_bridge<H: crate::types::Host>(
2981    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2982    dir: ScrollDir,
2983    count: usize,
2984) {
2985    let rows = viewport_full_rows(ed, count) as isize;
2986    match dir {
2987        ScrollDir::Down => scroll_cursor_rows(ed, rows),
2988        ScrollDir::Up => scroll_cursor_rows(ed, -rows),
2989    }
2990}
2991
2992/// `<C-d>` / `<C-u>` — scroll the cursor by half the viewport height.
2993/// `count` multiplies.
2994pub(crate) fn scroll_half_page_bridge<H: crate::types::Host>(
2995    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2996    dir: ScrollDir,
2997    count: usize,
2998) {
2999    let rows = viewport_half_rows(ed, count) as isize;
3000    match dir {
3001        ScrollDir::Down => scroll_cursor_rows(ed, rows),
3002        ScrollDir::Up => scroll_cursor_rows(ed, -rows),
3003    }
3004}
3005
3006/// `<C-e>` / `<C-y>` — scroll the viewport `count` lines without moving the
3007/// cursor (cursor is clamped to the new visible region if it would go
3008/// off-screen). `<C-e>` scrolls down; `<C-y>` scrolls up.
3009pub(crate) fn scroll_line_bridge<H: crate::types::Host>(
3010    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3011    dir: ScrollDir,
3012    count: usize,
3013) {
3014    let n = count.max(1);
3015    let total = buf_row_count(&ed.buffer);
3016    let last = total.saturating_sub(1);
3017    let h = ed.viewport_height_value() as usize;
3018    let vp = ed.host().viewport();
3019    let cur_top = vp.top_row;
3020    let new_top = match dir {
3021        ScrollDir::Down => (cur_top + n).min(last),
3022        ScrollDir::Up => cur_top.saturating_sub(n),
3023    };
3024    ed.set_viewport_top(new_top);
3025    // Clamp cursor to stay within the new visible region.
3026    let (row, col) = ed.cursor();
3027    let bot = (new_top + h).saturating_sub(1).min(last);
3028    let clamped = row.max(new_top).min(bot);
3029    if clamped != row {
3030        buf_set_cursor_rc(&mut ed.buffer, clamped, col);
3031        ed.push_buffer_cursor_to_textarea();
3032    }
3033}
3034
3035// ── Search bridges ─────────────────────────────────────────────────────────
3036
3037/// `n` / `N` — repeat the last search `count` times. `forward = true` means
3038/// repeat in the original search direction; `false` inverts it (like `N`).
3039pub(crate) fn search_repeat_bridge<H: crate::types::Host>(
3040    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3041    forward: bool,
3042    count: usize,
3043) {
3044    if let Some(pattern) = ed.vim.last_search.clone() {
3045        ed.push_search_pattern(&pattern);
3046    }
3047    if ed.search_state().pattern.is_none() {
3048        return;
3049    }
3050    let go_forward = ed.vim.last_search_forward == forward;
3051    for _ in 0..count.max(1) {
3052        if go_forward {
3053            ed.search_advance_forward(true);
3054        } else {
3055            ed.search_advance_backward(true);
3056        }
3057    }
3058    ed.push_buffer_cursor_to_textarea();
3059}
3060
3061/// `*` / `#` / `g*` / `g#` — search for the word under the cursor.
3062/// `forward` picks search direction; `whole_word` wraps in `\b...\b`.
3063/// `count` repeats the advance.
3064pub(crate) fn word_search_bridge<H: crate::types::Host>(
3065    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3066    forward: bool,
3067    whole_word: bool,
3068    count: usize,
3069) {
3070    word_at_cursor_search(ed, forward, whole_word, count.max(1));
3071}
3072
3073// ── Undo / redo confirmation wrappers (already public on Editor) ───────────
3074
3075/// `u` bridge — identical to `do_undo`; retained for Phase 6.6b audit.
3076/// The FSM now calls `ed.undo()` directly (Phase 6.6a).
3077#[allow(dead_code)]
3078#[inline]
3079pub(crate) fn do_undo_bridge<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
3080    do_undo(ed);
3081}
3082
3083// ─── Phase 6.3: visual-mode primitive bridges ──────────────────────────────
3084//
3085// Each `pub(crate)` free function is the extractable body of one visual-mode
3086// transition. These bridges set `vim.mode` directly AND write `current_mode`
3087// so that `Editor::vim_mode()` can read from the stable field without going
3088// through `public_mode()`.
3089//
3090// Pattern identical to Phase 6.1 / 6.2:
3091//   - Bridge fn is `pub(crate) fn *_bridge<H: Host>(ed, …)` in this file.
3092//   - Public wrapper is `pub fn *(&mut self, …)` in `editor.rs` with rustdoc.
3093
3094/// Drop the `Blame` view overlay whenever the input mode is no longer
3095/// `Normal`. BLAME is a Normal-only read-only view; entering Insert/Visual/etc.
3096/// (by keyboard, mouse drag, or programmatic transition) implicitly leaves it.
3097/// Called from every mode-transition funnel so the FSM is the single source of
3098/// truth — the host never has to police this.
3099#[inline]
3100pub(crate) fn drop_blame_if_left_normal<H: crate::types::Host>(
3101    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3102) {
3103    if ed.vim.current_mode != crate::VimMode::Normal {
3104        ed.vim.view = crate::ViewMode::Normal;
3105    }
3106}
3107
3108/// Helper — set both the FSM-internal `mode` and the stable `current_mode`
3109/// field in one call. Every Phase 6.3 bridge that changes mode calls this so
3110/// `vim_mode()` stays correct without going through the FSM's `step()` loop.
3111#[inline]
3112pub(crate) fn set_vim_mode_bridge<H: crate::types::Host>(
3113    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3114    mode: Mode,
3115) {
3116    ed.vim.mode = mode;
3117    ed.vim.current_mode = ed.vim.public_mode();
3118    drop_blame_if_left_normal(ed);
3119}
3120
3121/// `v` from Normal — enter charwise Visual mode. Anchors at the current
3122/// cursor position; the cursor IS the live end of the selection.
3123pub(crate) fn enter_visual_char_bridge<H: crate::types::Host>(
3124    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3125) {
3126    let cur = ed.cursor();
3127    ed.vim.visual_anchor = cur;
3128    set_vim_mode_bridge(ed, Mode::Visual);
3129}
3130
3131/// `V` from Normal — enter linewise Visual mode. Anchors the whole line
3132/// containing the current cursor; `o` still swaps the anchor row.
3133pub(crate) fn enter_visual_line_bridge<H: crate::types::Host>(
3134    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3135) {
3136    let (row, _) = ed.cursor();
3137    ed.vim.visual_line_anchor = row;
3138    set_vim_mode_bridge(ed, Mode::VisualLine);
3139}
3140
3141/// `<C-v>` from Normal — enter Visual-block mode. Anchors at the current
3142/// cursor; `block_vcol` is seeded from the cursor column so h/l navigation
3143/// preserves the desired virtual column.
3144pub(crate) fn enter_visual_block_bridge<H: crate::types::Host>(
3145    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3146) {
3147    let cur = ed.cursor();
3148    ed.vim.block_anchor = cur;
3149    ed.vim.block_vcol = cur.1;
3150    set_vim_mode_bridge(ed, Mode::VisualBlock);
3151}
3152
3153/// Esc from any visual mode — set `<` / `>` marks (per `:h v_:`), stash the
3154/// selection for `gv` re-entry, and return to Normal. Replicates the
3155/// `pre_visual_snapshot` logic in `step()` so callers outside the FSM get
3156/// identical behaviour.
3157pub(crate) fn exit_visual_to_normal_bridge<H: crate::types::Host>(
3158    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3159) {
3160    // Build the same snapshot that `step()` captures at pre-step time.
3161    let snap: Option<LastVisual> = match ed.vim.mode {
3162        Mode::Visual => Some(LastVisual {
3163            mode: Mode::Visual,
3164            anchor: ed.vim.visual_anchor,
3165            cursor: ed.cursor(),
3166            block_vcol: 0,
3167        }),
3168        Mode::VisualLine => Some(LastVisual {
3169            mode: Mode::VisualLine,
3170            anchor: (ed.vim.visual_line_anchor, 0),
3171            cursor: ed.cursor(),
3172            block_vcol: 0,
3173        }),
3174        Mode::VisualBlock => Some(LastVisual {
3175            mode: Mode::VisualBlock,
3176            anchor: ed.vim.block_anchor,
3177            cursor: ed.cursor(),
3178            block_vcol: ed.vim.block_vcol,
3179        }),
3180        _ => None,
3181    };
3182    // Transition to Normal first (matches FSM order).
3183    ed.vim.pending = Pending::None;
3184    ed.vim.count = 0;
3185    ed.vim.insert_session = None;
3186    set_vim_mode_bridge(ed, Mode::Normal);
3187    // Set `<` / `>` marks and stash `last_visual` — mirrors the post-step
3188    // logic in `step()` that fires when a visual → non-visual transition
3189    // is detected.
3190    if let Some(snap) = snap {
3191        let (lo, hi) = match snap.mode {
3192            Mode::Visual => {
3193                if snap.anchor <= snap.cursor {
3194                    (snap.anchor, snap.cursor)
3195                } else {
3196                    (snap.cursor, snap.anchor)
3197                }
3198            }
3199            Mode::VisualLine => {
3200                let r_lo = snap.anchor.0.min(snap.cursor.0);
3201                let r_hi = snap.anchor.0.max(snap.cursor.0);
3202                let vl_rope = ed.buffer().rope();
3203                let r_hi_clamped = r_hi.min(vl_rope.len_lines().saturating_sub(1));
3204                let last_col = hjkl_buffer::rope_line_str(&vl_rope, r_hi_clamped)
3205                    .chars()
3206                    .count()
3207                    .saturating_sub(1);
3208                ((r_lo, 0), (r_hi, last_col))
3209            }
3210            Mode::VisualBlock => {
3211                let (r1, c1) = snap.anchor;
3212                let (r2, c2) = snap.cursor;
3213                ((r1.min(r2), c1.min(c2)), (r1.max(r2), c1.max(c2)))
3214            }
3215            _ => {
3216                if snap.anchor <= snap.cursor {
3217                    (snap.anchor, snap.cursor)
3218                } else {
3219                    (snap.cursor, snap.anchor)
3220                }
3221            }
3222        };
3223        ed.set_mark('<', lo);
3224        ed.set_mark('>', hi);
3225        ed.vim.last_visual = Some(snap);
3226    }
3227}
3228
3229/// `o` in Visual / VisualLine / VisualBlock — swap the cursor and anchor
3230/// without mutating the selection range. In charwise mode the cursor jumps
3231/// to the old anchor and the anchor takes the old cursor. In linewise mode
3232/// the anchor *row* swaps with the current cursor row. In block mode the
3233/// block corners swap.
3234pub(crate) fn visual_o_toggle_bridge<H: crate::types::Host>(
3235    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3236) {
3237    match ed.vim.mode {
3238        Mode::Visual => {
3239            let cur = ed.cursor();
3240            let anchor = ed.vim.visual_anchor;
3241            ed.vim.visual_anchor = cur;
3242            ed.jump_cursor(anchor.0, anchor.1);
3243        }
3244        Mode::VisualLine => {
3245            let cur_row = ed.cursor().0;
3246            let anchor_row = ed.vim.visual_line_anchor;
3247            ed.vim.visual_line_anchor = cur_row;
3248            ed.jump_cursor(anchor_row, 0);
3249        }
3250        Mode::VisualBlock => {
3251            let cur = ed.cursor();
3252            let anchor = ed.vim.block_anchor;
3253            ed.vim.block_anchor = cur;
3254            ed.vim.block_vcol = anchor.1;
3255            ed.jump_cursor(anchor.0, anchor.1);
3256        }
3257        _ => {}
3258    }
3259}
3260
3261/// `gv` — restore the last visual selection (mode + anchor + cursor).
3262/// No-op if no selection was ever stored. Mirrors the `gv` arm in
3263/// `handle_normal_g`.
3264pub(crate) fn reenter_last_visual_bridge<H: crate::types::Host>(
3265    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3266) {
3267    if let Some(snap) = ed.vim.last_visual {
3268        match snap.mode {
3269            Mode::Visual => {
3270                ed.vim.visual_anchor = snap.anchor;
3271                set_vim_mode_bridge(ed, Mode::Visual);
3272            }
3273            Mode::VisualLine => {
3274                ed.vim.visual_line_anchor = snap.anchor.0;
3275                set_vim_mode_bridge(ed, Mode::VisualLine);
3276            }
3277            Mode::VisualBlock => {
3278                ed.vim.block_anchor = snap.anchor;
3279                ed.vim.block_vcol = snap.block_vcol;
3280                set_vim_mode_bridge(ed, Mode::VisualBlock);
3281            }
3282            _ => {}
3283        }
3284        ed.jump_cursor(snap.cursor.0, snap.cursor.1);
3285    }
3286}
3287
3288/// Direct mode-transition entry point for external controllers (e.g.
3289/// hjkl-vim). Sets both the FSM-internal `mode` and the stable
3290/// `current_mode`. Use sparingly — prefer the semantic primitives
3291/// (`enter_visual_char_bridge`, `enter_insert_i_bridge`, …) which also
3292/// set up the required bookkeeping (anchors, sessions, …).
3293pub(crate) fn set_mode_bridge<H: crate::types::Host>(
3294    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3295    mode: crate::VimMode,
3296) {
3297    let internal = match mode {
3298        crate::VimMode::Normal => Mode::Normal,
3299        crate::VimMode::Insert => Mode::Insert,
3300        crate::VimMode::Visual => Mode::Visual,
3301        crate::VimMode::VisualLine => Mode::VisualLine,
3302        crate::VimMode::VisualBlock => Mode::VisualBlock,
3303    };
3304    ed.vim.mode = internal;
3305    ed.vim.current_mode = mode;
3306    drop_blame_if_left_normal(ed);
3307}
3308
3309// ─── Normal / Visual / Operator-pending dispatcher removed in Phase 6.6g.3 ──
3310//
3311// `step_normal` and all private dispatch helpers (handle_after_op,
3312// handle_after_g, handle_after_z, handle_normal_only, etc.) were deleted.
3313// The canonical FSM body lives in `hjkl-vim::normal`. Use
3314// `hjkl_vim::dispatch_input` as the entry point.
3315//
3316// DELETED FUNCTION SIGNATURE (for archaeology):
3317// pub(crate) fn step_normal<H: crate::types::Host>(ed: ..., input: Input) -> bool {
3318
3319/// `m{ch}` — public controller entry point. Validates `ch` (must be
3320/// alphanumeric to match vim's mark-name rules) and records the current
3321/// cursor position under that name. Promoted to the public surface in 0.6.7
3322/// so the hjkl-vim `PendingState::SetMark` reducer can dispatch
3323/// `EngineCmd::SetMark` without re-entering the engine FSM.
3324/// `handle_set_mark` delegates here to avoid logic duplication.
3325pub(crate) fn set_mark_at_cursor<H: crate::types::Host>(
3326    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3327    ch: char,
3328) {
3329    if ch.is_ascii_lowercase() {
3330        let pos = ed.cursor();
3331        ed.set_mark(ch, pos);
3332    } else if ch.is_ascii_uppercase() {
3333        let pos = ed.cursor();
3334        let bid = ed.current_buffer_id();
3335        ed.set_global_mark(ch, bid, pos);
3336        tracing::debug!(
3337            mark = ch as u32,
3338            buffer_id = bid,
3339            row = pos.0,
3340            col = pos.1,
3341            "global mark set"
3342        );
3343    }
3344    // Invalid chars silently no-op (mirrors handle_set_mark behaviour).
3345}
3346
3347/// `'<ch>` / `` `<ch> `` — public controller entry point for lowercase and
3348/// special marks. Validates `ch` against the set of legal mark names
3349/// (lowercase, special: `'`/`` ` ``/`.`/`[`/`]`/`<`/`>`), resolves the
3350/// target position, and jumps the cursor. `linewise = true` → row only, col
3351/// snaps to first non-blank; `linewise = false` → exact (row, col).
3352///
3353/// Uppercase marks are handled by [`try_goto_mark`] which can return a
3354/// `MarkJump::CrossBuffer` for cross-buffer jumps.
3355pub(crate) fn goto_mark<H: crate::types::Host>(
3356    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3357    ch: char,
3358    linewise: bool,
3359) {
3360    let target = match ch {
3361        'a'..='z' => ed.mark(ch),
3362        '\'' | '`' => ed.vim.jump_back.last().copied(),
3363        '.' => ed.vim.last_edit_pos,
3364        '[' | ']' | '<' | '>' => ed.mark(ch),
3365        _ => None,
3366    };
3367    let Some((row, col)) = target else {
3368        return;
3369    };
3370    let pre = ed.cursor();
3371    let (r, c_clamped) = clamp_pos(ed, (row, col));
3372    if linewise {
3373        buf_set_cursor_rc(&mut ed.buffer, r, 0);
3374        ed.push_buffer_cursor_to_textarea();
3375        move_first_non_whitespace(ed);
3376    } else {
3377        buf_set_cursor_rc(&mut ed.buffer, r, c_clamped);
3378        ed.push_buffer_cursor_to_textarea();
3379    }
3380    if ed.cursor() != pre {
3381        ed.push_jump(pre);
3382    }
3383    ed.sticky_col = Some(ed.cursor().1);
3384}
3385
3386/// Unified mark-jump entry point that returns a [`crate::editor::MarkJump`]
3387/// so the app layer can decide whether to switch buffers.
3388///
3389/// - Uppercase marks (`'A'`–`'Z'`) look in `global_marks`. If the stored
3390///   `buffer_id` differs from `ed.current_buffer_id()`, returns
3391///   `CrossBuffer`. Same-buffer uppercase marks execute the jump normally.
3392/// - All other legal mark chars delegate to [`goto_mark`] and return
3393///   `SameBuffer`.
3394pub(crate) fn try_goto_mark<H: crate::types::Host>(
3395    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3396    ch: char,
3397    linewise: bool,
3398) -> crate::editor::MarkJump {
3399    use crate::editor::MarkJump;
3400    match ch {
3401        'A'..='Z' => {
3402            let Some((bid, row, col)) = ed.global_mark(ch) else {
3403                return MarkJump::Unset;
3404            };
3405            if bid != ed.current_buffer_id() {
3406                tracing::debug!(
3407                    mark = ch as u32,
3408                    buffer_id = bid,
3409                    row,
3410                    col,
3411                    "global mark cross-buffer jump"
3412                );
3413                return MarkJump::CrossBuffer {
3414                    buffer_id: bid,
3415                    row,
3416                    col,
3417                };
3418            }
3419            // Same buffer — execute the jump normally.
3420            let pre = ed.cursor();
3421            let (r, c_clamped) = clamp_pos(ed, (row, col));
3422            if linewise {
3423                buf_set_cursor_rc(&mut ed.buffer, r, 0);
3424                ed.push_buffer_cursor_to_textarea();
3425                move_first_non_whitespace(ed);
3426            } else {
3427                buf_set_cursor_rc(&mut ed.buffer, r, c_clamped);
3428                ed.push_buffer_cursor_to_textarea();
3429            }
3430            if ed.cursor() != pre {
3431                ed.push_jump(pre);
3432            }
3433            ed.sticky_col = Some(ed.cursor().1);
3434            MarkJump::SameBuffer
3435        }
3436        'a'..='z' | '\'' | '`' | '.' | '[' | ']' | '<' | '>' => {
3437            goto_mark(ed, ch, linewise);
3438            MarkJump::SameBuffer
3439        }
3440        _ => MarkJump::Unset,
3441    }
3442}
3443
3444/// `true` when `op` records a `last_change` entry for dot-repeat purposes.
3445/// Promoted to `pub` in Phase 6.6e so `hjkl-vim::normal` can use it without
3446/// duplicating the logic.
3447pub fn op_is_change(op: Operator) -> bool {
3448    matches!(op, Operator::Delete | Operator::Change)
3449}
3450
3451// ─── Jumplist (Ctrl-o / Ctrl-i) ────────────────────────────────────────────
3452
3453/// Max jumplist depth. Matches vim default.
3454pub(crate) const JUMPLIST_MAX: usize = 100;
3455
3456/// `Ctrl-o` — jump back to the most recent pre-jump position. Saves
3457/// the current cursor onto the forward stack so `Ctrl-i` can return.
3458fn jump_back<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
3459    let Some(target) = ed.vim.jump_back.pop() else {
3460        return;
3461    };
3462    let cur = ed.cursor();
3463    ed.vim.jump_fwd.push(cur);
3464    let (r, c) = clamp_pos(ed, target);
3465    ed.jump_cursor(r, c);
3466    ed.sticky_col = Some(c);
3467}
3468
3469/// `Ctrl-i` / `Tab` — redo the last `Ctrl-o`. Saves the current cursor
3470/// onto the back stack.
3471fn jump_forward<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
3472    let Some(target) = ed.vim.jump_fwd.pop() else {
3473        return;
3474    };
3475    let cur = ed.cursor();
3476    ed.vim.jump_back.push(cur);
3477    if ed.vim.jump_back.len() > JUMPLIST_MAX {
3478        ed.vim.jump_back.remove(0);
3479    }
3480    let (r, c) = clamp_pos(ed, target);
3481    ed.jump_cursor(r, c);
3482    ed.sticky_col = Some(c);
3483}
3484
3485/// Clamp a stored `(row, col)` to the live buffer in case edits
3486/// shrunk the document between push and pop.
3487fn clamp_pos<H: crate::types::Host>(
3488    ed: &Editor<hjkl_buffer::Buffer, H>,
3489    pos: (usize, usize),
3490) -> (usize, usize) {
3491    let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
3492    let r = pos.0.min(last_row);
3493    let line_len = buf_line_chars(&ed.buffer, r);
3494    let c = pos.1.min(line_len.saturating_sub(1));
3495    (r, c)
3496}
3497
3498/// True for motions that vim treats as jumps (pushed onto the jumplist).
3499fn is_big_jump(motion: &Motion) -> bool {
3500    matches!(
3501        motion,
3502        Motion::FileTop
3503            | Motion::FileBottom
3504            | Motion::MatchBracket
3505            | Motion::WordAtCursor { .. }
3506            | Motion::SearchNext { .. }
3507            | Motion::ViewportTop
3508            | Motion::ViewportMiddle
3509            | Motion::ViewportBottom
3510    )
3511}
3512
3513// ─── Scroll helpers (Ctrl-d / Ctrl-u / Ctrl-f / Ctrl-b) ────────────────────
3514
3515/// Half-viewport row count, with a floor of 1 so tiny / un-rendered
3516/// viewports still step by a single row. `count` multiplies.
3517fn viewport_half_rows<H: crate::types::Host>(
3518    ed: &Editor<hjkl_buffer::Buffer, H>,
3519    count: usize,
3520) -> usize {
3521    let h = ed.viewport_height_value() as usize;
3522    (h / 2).max(1).saturating_mul(count.max(1))
3523}
3524
3525/// Full-viewport row count. Vim conventionally keeps 2 lines of overlap
3526/// between successive `Ctrl-f` pages; we approximate with `h - 2`.
3527fn viewport_full_rows<H: crate::types::Host>(
3528    ed: &Editor<hjkl_buffer::Buffer, H>,
3529    count: usize,
3530) -> usize {
3531    let h = ed.viewport_height_value() as usize;
3532    h.saturating_sub(2).max(1).saturating_mul(count.max(1))
3533}
3534
3535/// Move the cursor by `delta` rows (positive = down, negative = up),
3536/// clamp to the document, then land at the first non-blank on the new
3537/// row. The textarea viewport auto-scrolls to keep the cursor visible
3538/// when the cursor pushes off-screen.
3539fn scroll_cursor_rows<H: crate::types::Host>(
3540    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3541    delta: isize,
3542) {
3543    if delta == 0 {
3544        return;
3545    }
3546    ed.sync_buffer_content_from_textarea();
3547    let (row, _) = ed.cursor();
3548    let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
3549    let target = (row as isize + delta).max(0).min(last_row as isize) as usize;
3550    buf_set_cursor_rc(&mut ed.buffer, target, 0);
3551    crate::motions::move_first_non_blank(&mut ed.buffer);
3552    ed.push_buffer_cursor_to_textarea();
3553    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
3554}
3555
3556// ─── Motion parsing ────────────────────────────────────────────────────────
3557
3558/// Parse the first key of a normal/visual-mode motion. Returns `None` for
3559/// keys that don't start a motion (operator keys, command keys, etc.).
3560/// Promoted to `pub` in Phase 6.6e so `hjkl-vim::normal` can call it.
3561pub fn parse_motion(input: &Input) -> Option<Motion> {
3562    if input.ctrl {
3563        return None;
3564    }
3565    match input.key {
3566        Key::Char('h') | Key::Backspace | Key::Left => Some(Motion::Left),
3567        Key::Char('l') | Key::Right => Some(Motion::Right),
3568        Key::Char('j') | Key::Down => Some(Motion::Down),
3569        // `+` / `<CR>` — first non-blank of next line (linewise, count-aware).
3570        Key::Char('+') | Key::Enter => Some(Motion::FirstNonBlankNextLine),
3571        // `-` — first non-blank of previous line (linewise, count-aware).
3572        Key::Char('-') => Some(Motion::FirstNonBlankPrevLine),
3573        // `_` — first non-blank of current line, or count-1 lines down (linewise).
3574        Key::Char('_') => Some(Motion::FirstNonBlankLine),
3575        Key::Char('k') | Key::Up => Some(Motion::Up),
3576        Key::Char('w') => Some(Motion::WordFwd),
3577        Key::Char('W') => Some(Motion::BigWordFwd),
3578        Key::Char('b') => Some(Motion::WordBack),
3579        Key::Char('B') => Some(Motion::BigWordBack),
3580        Key::Char('e') => Some(Motion::WordEnd),
3581        Key::Char('E') => Some(Motion::BigWordEnd),
3582        Key::Char('0') | Key::Home => Some(Motion::LineStart),
3583        Key::Char('^') => Some(Motion::FirstNonBlank),
3584        Key::Char('$') | Key::End => Some(Motion::LineEnd),
3585        Key::Char('G') => Some(Motion::FileBottom),
3586        Key::Char('%') => Some(Motion::MatchBracket),
3587        Key::Char(';') => Some(Motion::FindRepeat { reverse: false }),
3588        Key::Char(',') => Some(Motion::FindRepeat { reverse: true }),
3589        Key::Char('*') => Some(Motion::WordAtCursor {
3590            forward: true,
3591            whole_word: true,
3592        }),
3593        Key::Char('#') => Some(Motion::WordAtCursor {
3594            forward: false,
3595            whole_word: true,
3596        }),
3597        Key::Char('n') => Some(Motion::SearchNext { reverse: false }),
3598        Key::Char('N') => Some(Motion::SearchNext { reverse: true }),
3599        Key::Char('H') => Some(Motion::ViewportTop),
3600        Key::Char('M') => Some(Motion::ViewportMiddle),
3601        Key::Char('L') => Some(Motion::ViewportBottom),
3602        Key::Char('{') => Some(Motion::ParagraphPrev),
3603        Key::Char('}') => Some(Motion::ParagraphNext),
3604        Key::Char('(') => Some(Motion::SentencePrev),
3605        Key::Char(')') => Some(Motion::SentenceNext),
3606        Key::Char('|') => Some(Motion::GotoColumn),
3607        _ => None,
3608    }
3609}
3610
3611// ─── Motion execution ──────────────────────────────────────────────────────
3612
3613pub(crate) fn execute_motion<H: crate::types::Host>(
3614    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3615    motion: Motion,
3616    count: usize,
3617) {
3618    let count = count.max(1);
3619    // `;`/`,` smart fallback: if the last horizontal motion was a sneak
3620    // digraph, repeat via apply_sneak instead of find-char.
3621    if let Motion::FindRepeat { reverse } = motion
3622        && ed.vim.last_horizontal_motion == LastHorizontalMotion::Sneak
3623    {
3624        if let Some(((c1, c2), fwd)) = ed.vim.last_sneak {
3625            let effective_fwd = if reverse { !fwd } else { fwd };
3626            apply_sneak(ed, c1, c2, effective_fwd, count);
3627        }
3628        return;
3629    }
3630    // FindRepeat needs the stored direction.
3631    let motion = match motion {
3632        Motion::FindRepeat { reverse } => match ed.vim.last_find {
3633            Some((ch, forward, till)) => Motion::Find {
3634                ch,
3635                forward: if reverse { !forward } else { forward },
3636                till,
3637            },
3638            None => return,
3639        },
3640        other => other,
3641    };
3642    let pre_pos = ed.cursor();
3643    let pre_col = pre_pos.1;
3644    apply_motion_cursor(ed, &motion, count);
3645    let post_pos = ed.cursor();
3646    if is_big_jump(&motion) && pre_pos != post_pos {
3647        ed.push_jump(pre_pos);
3648    }
3649    apply_sticky_col(ed, &motion, pre_col);
3650    // Phase 7b: keep the migration buffer's cursor + viewport in
3651    // lockstep with the textarea after every motion. Once 7c lands
3652    // (motions ported onto the buffer's API), this flips: the
3653    // buffer becomes authoritative and the textarea mirrors it.
3654    ed.sync_buffer_from_textarea();
3655}
3656
3657// ─── Keymap-layer motion controller ────────────────────────────────────────
3658
3659/// Wrapper around `execute_motion` that also syncs `block_vcol` when in
3660/// VisualBlock mode. The engine FSM's `step()` already does this (line ~2001);
3661/// the keymap path (`apply_motion_kind`) must do the same so VisualBlock h/l
3662/// extend the highlighted region correctly.
3663///
3664/// `update_block_vcol` is only a no-op for vertical / non-horizontal motions
3665/// (Up, Down, FileTop, FileBottom, Search), so passing every motion through is
3666/// safe — the function's own match arm handles the no-op case.
3667fn execute_motion_with_block_vcol<H: crate::types::Host>(
3668    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3669    motion: Motion,
3670    count: usize,
3671) {
3672    let motion_copy = motion.clone();
3673    execute_motion(ed, motion, count);
3674    if ed.vim.mode == Mode::VisualBlock {
3675        update_block_vcol(ed, &motion_copy);
3676    }
3677}
3678
3679/// Execute a `crate::MotionKind` cursor motion. Called by the host's
3680/// `Editor::apply_motion` controller method — the keymap dispatch path for
3681/// Phase 3a of kryptic-sh/hjkl#69.
3682///
3683/// Maps each variant to the same internal primitives used by the engine FSM
3684/// so cursor, sticky column, scroll, and sync semantics are identical.
3685///
3686/// # Visual-mode post-motion sync audit (2026-05-13)
3687///
3688/// After `execute_motion`, two things are conditional on visual mode:
3689///
3690/// 1. **VisualBlock `block_vcol` sync** — `update_block_vcol(ed, &motion)` is
3691///    called when `mode == Mode::VisualBlock`.  This is replicated here via
3692///    `execute_motion_with_block_vcol` for every motion variant below.
3693///
3694/// 2. **`last_find` update** — `Motion::Find` is dispatched through
3695///    `Pending::Find → apply_find_char` (in hjkl-vim), which writes `last_find`
3696///    itself.  A post-motion `last_find` write here would be dead code.  The keymap
3697///    path writes `last_find` in `apply_find_char` (called from
3698///    `Editor::find_char`), so no gap exists here.
3699///
3700/// No VisualLine-specific or Visual-specific post-motion work exists in the
3701/// FSM: anchors (`visual_anchor`, `visual_line_anchor`, `block_anchor`) are
3702/// only written on mode-entry or `o`-swap, never on motion.  The `<`/`>`
3703/// mark update in `step()` fires only on visual→normal transition, not after
3704/// each motion.  There are **no further sync gaps** beyond the `block_vcol`
3705/// fix already applied above.
3706pub(crate) fn apply_motion_kind<H: crate::types::Host>(
3707    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3708    kind: crate::MotionKind,
3709    count: usize,
3710) {
3711    let count = count.max(1);
3712    match kind {
3713        crate::MotionKind::CharLeft => {
3714            execute_motion_with_block_vcol(ed, Motion::Left, count);
3715        }
3716        crate::MotionKind::CharRight => {
3717            execute_motion_with_block_vcol(ed, Motion::Right, count);
3718        }
3719        crate::MotionKind::LineDown => {
3720            execute_motion_with_block_vcol(ed, Motion::Down, count);
3721        }
3722        crate::MotionKind::LineUp => {
3723            execute_motion_with_block_vcol(ed, Motion::Up, count);
3724        }
3725        crate::MotionKind::FirstNonBlankDown => {
3726            // `+`: move down `count` lines then land on first non-blank.
3727            // Not a big-jump (no jump-list entry), sticky col set to the
3728            // landed column (first non-blank). Mirrors scroll_cursor_rows
3729            // semantics but goes through the fold-aware buffer motion path.
3730            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3731            crate::motions::move_down(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
3732            crate::motions::move_first_non_blank(&mut ed.buffer);
3733            ed.push_buffer_cursor_to_textarea();
3734            ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
3735            ed.sync_buffer_from_textarea();
3736        }
3737        crate::MotionKind::FirstNonBlankUp => {
3738            // `-`: move up `count` lines then land on first non-blank.
3739            // Same pattern as FirstNonBlankDown, direction reversed.
3740            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3741            crate::motions::move_up(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
3742            crate::motions::move_first_non_blank(&mut ed.buffer);
3743            ed.push_buffer_cursor_to_textarea();
3744            ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
3745            ed.sync_buffer_from_textarea();
3746        }
3747        crate::MotionKind::WordForward => {
3748            execute_motion_with_block_vcol(ed, Motion::WordFwd, count);
3749        }
3750        crate::MotionKind::BigWordForward => {
3751            execute_motion_with_block_vcol(ed, Motion::BigWordFwd, count);
3752        }
3753        crate::MotionKind::WordBackward => {
3754            execute_motion_with_block_vcol(ed, Motion::WordBack, count);
3755        }
3756        crate::MotionKind::BigWordBackward => {
3757            execute_motion_with_block_vcol(ed, Motion::BigWordBack, count);
3758        }
3759        crate::MotionKind::WordEnd => {
3760            execute_motion_with_block_vcol(ed, Motion::WordEnd, count);
3761        }
3762        crate::MotionKind::BigWordEnd => {
3763            execute_motion_with_block_vcol(ed, Motion::BigWordEnd, count);
3764        }
3765        crate::MotionKind::LineStart => {
3766            // `0` / `<Home>`: first column of the current line.
3767            // count is ignored — matches vim `0` semantics.
3768            execute_motion_with_block_vcol(ed, Motion::LineStart, 1);
3769        }
3770        crate::MotionKind::FirstNonBlank => {
3771            // `^`: first non-blank column on the current line.
3772            // count is ignored — matches vim `^` semantics.
3773            execute_motion_with_block_vcol(ed, Motion::FirstNonBlank, 1);
3774        }
3775        crate::MotionKind::GotoLine => {
3776            // `G`: bare `G` → last line; `count G` → jump to line `count`.
3777            // apply_motion_kind normalises the raw count to count.max(1)
3778            // above, so count == 1 means "bare G" (last line) and count > 1
3779            // means "go to line N". execute_motion's FileBottom arm applies
3780            // the same `count > 1` check before calling move_bottom, so the
3781            // convention aligns: pass count straight through.
3782            // FileBottom is vertical — update_block_vcol is a no-op here
3783            // (preserves vcol), so the helper is safe to use.
3784            execute_motion_with_block_vcol(ed, Motion::FileBottom, count);
3785        }
3786        crate::MotionKind::LineEnd => {
3787            // `$` / `<End>`: last character on the current line.
3788            // count is ignored at the keymap-path level (vim `N$` moves
3789            // down N-1 lines then lands at line-end; not yet wired).
3790            execute_motion_with_block_vcol(ed, Motion::LineEnd, 1);
3791        }
3792        crate::MotionKind::FindRepeat => {
3793            // `;` — repeat last f/F/t/T in the same direction.
3794            // execute_motion resolves FindRepeat via ed.vim.last_find;
3795            // no-op if no prior find exists (None arm returns early).
3796            execute_motion_with_block_vcol(ed, Motion::FindRepeat { reverse: false }, count);
3797        }
3798        crate::MotionKind::FindRepeatReverse => {
3799            // `,` — repeat last f/F/t/T in the reverse direction.
3800            // execute_motion resolves FindRepeat via ed.vim.last_find;
3801            // no-op if no prior find exists (None arm returns early).
3802            execute_motion_with_block_vcol(ed, Motion::FindRepeat { reverse: true }, count);
3803        }
3804        crate::MotionKind::BracketMatch => {
3805            // `%` — jump to the matching bracket.
3806            // count is passed through; engine-side matching_bracket handles
3807            // the no-match case as a no-op (cursor stays). Engine FSM arm
3808            // for `%` in parse_motion is kept intact for macro-replay.
3809            execute_motion_with_block_vcol(ed, Motion::MatchBracket, count);
3810        }
3811        crate::MotionKind::ViewportTop => {
3812            // `H` — cursor to top of visible viewport, then count-1 rows down.
3813            // Engine FSM arm for `H` in parse_motion is kept intact for macro-replay.
3814            execute_motion_with_block_vcol(ed, Motion::ViewportTop, count);
3815        }
3816        crate::MotionKind::ViewportMiddle => {
3817            // `M` — cursor to middle of visible viewport; count ignored.
3818            // Engine FSM arm for `M` in parse_motion is kept intact for macro-replay.
3819            execute_motion_with_block_vcol(ed, Motion::ViewportMiddle, count);
3820        }
3821        crate::MotionKind::ViewportBottom => {
3822            // `L` — cursor to bottom of visible viewport, then count-1 rows up.
3823            // Engine FSM arm for `L` in parse_motion is kept intact for macro-replay.
3824            execute_motion_with_block_vcol(ed, Motion::ViewportBottom, count);
3825        }
3826        crate::MotionKind::HalfPageDown => {
3827            // `<C-d>` — half page down, count multiplies the distance.
3828            // Calls scroll_cursor_rows directly rather than adding a Motion enum
3829            // variant, keeping engine Motion churn minimal.
3830            scroll_cursor_rows(ed, viewport_half_rows(ed, count) as isize);
3831        }
3832        crate::MotionKind::HalfPageUp => {
3833            // `<C-u>` — half page up, count multiplies the distance.
3834            // Direct call mirrors the FSM Ctrl-u arm. No new Motion variant.
3835            scroll_cursor_rows(ed, -(viewport_half_rows(ed, count) as isize));
3836        }
3837        crate::MotionKind::FullPageDown => {
3838            // `<C-f>` — full page down (2-line overlap), count multiplies.
3839            // Direct call mirrors the FSM Ctrl-f arm. No new Motion variant.
3840            scroll_cursor_rows(ed, viewport_full_rows(ed, count) as isize);
3841        }
3842        crate::MotionKind::FullPageUp => {
3843            // `<C-b>` — full page up (2-line overlap), count multiplies.
3844            // Direct call mirrors the FSM Ctrl-b arm. No new Motion variant.
3845            scroll_cursor_rows(ed, -(viewport_full_rows(ed, count) as isize));
3846        }
3847        crate::MotionKind::FirstNonBlankLine => {
3848            execute_motion_with_block_vcol(ed, Motion::FirstNonBlankLine, count);
3849        }
3850        crate::MotionKind::SectionBackward => {
3851            execute_motion_with_block_vcol(ed, Motion::SectionBackward, count);
3852        }
3853        crate::MotionKind::SectionForward => {
3854            execute_motion_with_block_vcol(ed, Motion::SectionForward, count);
3855        }
3856        crate::MotionKind::SectionEndBackward => {
3857            execute_motion_with_block_vcol(ed, Motion::SectionEndBackward, count);
3858        }
3859        crate::MotionKind::SectionEndForward => {
3860            execute_motion_with_block_vcol(ed, Motion::SectionEndForward, count);
3861        }
3862    }
3863}
3864
3865/// Restore the cursor to the sticky column after vertical motions and
3866/// sync the sticky column to the current column after horizontal ones.
3867/// `pre_col` is the cursor column captured *before* the motion — used
3868/// to bootstrap the sticky value on the very first motion.
3869fn apply_sticky_col<H: crate::types::Host>(
3870    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3871    motion: &Motion,
3872    pre_col: usize,
3873) {
3874    if is_vertical_motion(motion) {
3875        let want = ed.sticky_col.unwrap_or(pre_col);
3876        // Record the desired column so the next vertical motion sees
3877        // it even if we currently clamped to a shorter row.
3878        ed.sticky_col = Some(want);
3879        let (row, _) = ed.cursor();
3880        let line_len = buf_line_chars(&ed.buffer, row);
3881        // Clamp to the last char on non-empty lines (vim normal-mode
3882        // never parks the cursor one past end of line). Empty lines
3883        // collapse to col 0.
3884        let max_col = line_len.saturating_sub(1);
3885        let target = want.min(max_col);
3886        // raw primitive: this function MUST preserve the un-clamped `want`
3887        // already stored in `ed.sticky_col`; `jump_cursor` would overwrite
3888        // it with the clamped `target`.
3889        buf_set_cursor_rc(&mut ed.buffer, row, target);
3890    } else {
3891        // Horizontal motion or non-motion: sticky column tracks the
3892        // new cursor column so the *next* vertical motion aims there.
3893        ed.sticky_col = Some(ed.cursor().1);
3894    }
3895}
3896
3897fn is_vertical_motion(motion: &Motion) -> bool {
3898    // Only j / k preserve the sticky column. Everything else (search,
3899    // gg / G, word jumps, etc.) lands at the match's own column so the
3900    // sticky value should sync to the new cursor column.
3901    matches!(
3902        motion,
3903        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown
3904    )
3905}
3906
3907fn apply_motion_cursor<H: crate::types::Host>(
3908    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3909    motion: &Motion,
3910    count: usize,
3911) {
3912    apply_motion_cursor_ctx(ed, motion, count, false)
3913}
3914
3915pub(crate) fn apply_motion_cursor_ctx<H: crate::types::Host>(
3916    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3917    motion: &Motion,
3918    count: usize,
3919    as_operator: bool,
3920) {
3921    match motion {
3922        Motion::Left => {
3923            // `h` — Buffer clamps at col 0 (no wrap), matching vim.
3924            crate::motions::move_left(&mut ed.buffer, count);
3925            ed.push_buffer_cursor_to_textarea();
3926        }
3927        Motion::Right => {
3928            // `l` — operator-motion context (`dl`/`cl`/`yl`) is allowed
3929            // one past the last char so the range includes it; cursor
3930            // context clamps at the last char.
3931            if as_operator {
3932                crate::motions::move_right_to_end(&mut ed.buffer, count);
3933            } else {
3934                crate::motions::move_right_in_line(&mut ed.buffer, count);
3935            }
3936            ed.push_buffer_cursor_to_textarea();
3937        }
3938        Motion::Up => {
3939            // Final col is set by `apply_sticky_col` below — push the
3940            // post-move row to the textarea and let sticky tracking
3941            // finish the work.
3942            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3943            crate::motions::move_up(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
3944            ed.push_buffer_cursor_to_textarea();
3945        }
3946        Motion::Down => {
3947            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3948            crate::motions::move_down(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
3949            ed.push_buffer_cursor_to_textarea();
3950        }
3951        Motion::ScreenUp => {
3952            let v = *ed.host.viewport();
3953            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3954            crate::motions::move_screen_up(&mut ed.buffer, &folds, &v, count, &mut ed.sticky_col);
3955            ed.push_buffer_cursor_to_textarea();
3956        }
3957        Motion::ScreenDown => {
3958            let v = *ed.host.viewport();
3959            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3960            crate::motions::move_screen_down(&mut ed.buffer, &folds, &v, count, &mut ed.sticky_col);
3961            ed.push_buffer_cursor_to_textarea();
3962        }
3963        Motion::WordFwd => {
3964            crate::motions::move_word_fwd(&mut ed.buffer, false, count, &ed.settings.iskeyword);
3965            ed.push_buffer_cursor_to_textarea();
3966        }
3967        Motion::WordBack => {
3968            crate::motions::move_word_back(&mut ed.buffer, false, count, &ed.settings.iskeyword);
3969            ed.push_buffer_cursor_to_textarea();
3970        }
3971        Motion::WordEnd => {
3972            crate::motions::move_word_end(&mut ed.buffer, false, count, &ed.settings.iskeyword);
3973            ed.push_buffer_cursor_to_textarea();
3974        }
3975        Motion::BigWordFwd => {
3976            crate::motions::move_word_fwd(&mut ed.buffer, true, count, &ed.settings.iskeyword);
3977            ed.push_buffer_cursor_to_textarea();
3978        }
3979        Motion::BigWordBack => {
3980            crate::motions::move_word_back(&mut ed.buffer, true, count, &ed.settings.iskeyword);
3981            ed.push_buffer_cursor_to_textarea();
3982        }
3983        Motion::BigWordEnd => {
3984            crate::motions::move_word_end(&mut ed.buffer, true, count, &ed.settings.iskeyword);
3985            ed.push_buffer_cursor_to_textarea();
3986        }
3987        Motion::WordEndBack => {
3988            crate::motions::move_word_end_back(
3989                &mut ed.buffer,
3990                false,
3991                count,
3992                &ed.settings.iskeyword,
3993            );
3994            ed.push_buffer_cursor_to_textarea();
3995        }
3996        Motion::BigWordEndBack => {
3997            crate::motions::move_word_end_back(&mut ed.buffer, true, count, &ed.settings.iskeyword);
3998            ed.push_buffer_cursor_to_textarea();
3999        }
4000        Motion::LineStart => {
4001            crate::motions::move_line_start(&mut ed.buffer);
4002            ed.push_buffer_cursor_to_textarea();
4003        }
4004        Motion::FirstNonBlank => {
4005            crate::motions::move_first_non_blank(&mut ed.buffer);
4006            ed.push_buffer_cursor_to_textarea();
4007        }
4008        Motion::LineEnd => {
4009            // Vim normal-mode `$` lands on the last char, not one past it.
4010            crate::motions::move_line_end(&mut ed.buffer);
4011            ed.push_buffer_cursor_to_textarea();
4012        }
4013        Motion::FileTop => {
4014            // `count gg` jumps to line `count` (first non-blank);
4015            // bare `gg` lands at the top.
4016            if count > 1 {
4017                crate::motions::move_bottom(&mut ed.buffer, count);
4018            } else {
4019                crate::motions::move_top(&mut ed.buffer);
4020            }
4021            ed.push_buffer_cursor_to_textarea();
4022        }
4023        Motion::FileBottom => {
4024            // `count G` jumps to line `count`; bare `G` lands at
4025            // the buffer bottom (`Buffer::move_bottom(0)`).
4026            if count > 1 {
4027                crate::motions::move_bottom(&mut ed.buffer, count);
4028            } else {
4029                crate::motions::move_bottom(&mut ed.buffer, 0);
4030            }
4031            ed.push_buffer_cursor_to_textarea();
4032        }
4033        Motion::Find { ch, forward, till } => {
4034            for _ in 0..count {
4035                if !find_char_on_line(ed, *ch, *forward, *till) {
4036                    break;
4037                }
4038            }
4039        }
4040        Motion::FindRepeat { .. } => {} // already resolved upstream
4041        Motion::MatchBracket => {
4042            let _ = matching_bracket(ed);
4043        }
4044        Motion::UnmatchedBracket { forward, open } => {
4045            goto_unmatched_bracket(ed, *forward, *open, count);
4046        }
4047        Motion::WordAtCursor {
4048            forward,
4049            whole_word,
4050        } => {
4051            word_at_cursor_search(ed, *forward, *whole_word, count);
4052        }
4053        Motion::SearchNext { reverse } => {
4054            // Re-push the last query so the buffer's search state is
4055            // correct even if the host happened to clear it (e.g. while
4056            // a Visual mode draw was in progress).
4057            if let Some(pattern) = ed.vim.last_search.clone() {
4058                ed.push_search_pattern(&pattern);
4059            }
4060            if ed.search_state().pattern.is_none() {
4061                return;
4062            }
4063            // `n` repeats the last search in its committed direction;
4064            // `N` inverts. So a `?` search makes `n` walk backward and
4065            // `N` walk forward.
4066            let forward = ed.vim.last_search_forward != *reverse;
4067            for _ in 0..count.max(1) {
4068                if forward {
4069                    ed.search_advance_forward(true);
4070                } else {
4071                    ed.search_advance_backward(true);
4072                }
4073            }
4074            ed.push_buffer_cursor_to_textarea();
4075        }
4076        Motion::ViewportTop => {
4077            let v = *ed.host().viewport();
4078            crate::motions::move_viewport_top(&mut ed.buffer, &v, count.saturating_sub(1));
4079            ed.push_buffer_cursor_to_textarea();
4080        }
4081        Motion::ViewportMiddle => {
4082            let v = *ed.host().viewport();
4083            crate::motions::move_viewport_middle(&mut ed.buffer, &v);
4084            ed.push_buffer_cursor_to_textarea();
4085        }
4086        Motion::ViewportBottom => {
4087            let v = *ed.host().viewport();
4088            crate::motions::move_viewport_bottom(&mut ed.buffer, &v, count.saturating_sub(1));
4089            ed.push_buffer_cursor_to_textarea();
4090        }
4091        Motion::LastNonBlank => {
4092            crate::motions::move_last_non_blank(&mut ed.buffer);
4093            ed.push_buffer_cursor_to_textarea();
4094        }
4095        Motion::LineMiddle => {
4096            let row = ed.cursor().0;
4097            let line_chars = buf_line_chars(&ed.buffer, row);
4098            // Vim's `gM`: column = floor(chars / 2). Empty / single-char
4099            // lines stay at col 0.
4100            let target = line_chars / 2;
4101            ed.jump_cursor(row, target);
4102        }
4103        Motion::ParagraphPrev => {
4104            crate::motions::move_paragraph_prev(&mut ed.buffer, count);
4105            ed.push_buffer_cursor_to_textarea();
4106        }
4107        Motion::ParagraphNext => {
4108            crate::motions::move_paragraph_next(&mut ed.buffer, count);
4109            ed.push_buffer_cursor_to_textarea();
4110        }
4111        Motion::SentencePrev => {
4112            for _ in 0..count.max(1) {
4113                if let Some((row, col)) = sentence_boundary(ed, false) {
4114                    ed.jump_cursor(row, col);
4115                }
4116            }
4117        }
4118        Motion::SentenceNext => {
4119            for _ in 0..count.max(1) {
4120                if let Some((row, col)) = sentence_boundary(ed, true) {
4121                    ed.jump_cursor(row, col);
4122                }
4123            }
4124        }
4125        Motion::SectionBackward => {
4126            crate::motions::move_section_backward(&mut ed.buffer, count);
4127            ed.push_buffer_cursor_to_textarea();
4128        }
4129        Motion::SectionForward => {
4130            crate::motions::move_section_forward(&mut ed.buffer, count);
4131            ed.push_buffer_cursor_to_textarea();
4132        }
4133        Motion::SectionEndBackward => {
4134            crate::motions::move_section_end_backward(&mut ed.buffer, count);
4135            ed.push_buffer_cursor_to_textarea();
4136        }
4137        Motion::SectionEndForward => {
4138            crate::motions::move_section_end_forward(&mut ed.buffer, count);
4139            ed.push_buffer_cursor_to_textarea();
4140        }
4141        Motion::FirstNonBlankNextLine => {
4142            crate::motions::move_first_non_blank_next_line(&mut ed.buffer, count);
4143            ed.push_buffer_cursor_to_textarea();
4144        }
4145        Motion::FirstNonBlankPrevLine => {
4146            crate::motions::move_first_non_blank_prev_line(&mut ed.buffer, count);
4147            ed.push_buffer_cursor_to_textarea();
4148        }
4149        Motion::FirstNonBlankLine => {
4150            crate::motions::move_first_non_blank_line(&mut ed.buffer, count);
4151            ed.push_buffer_cursor_to_textarea();
4152        }
4153        Motion::GotoColumn => {
4154            crate::motions::move_goto_column(&mut ed.buffer, count);
4155            ed.push_buffer_cursor_to_textarea();
4156        }
4157    }
4158}
4159
4160fn move_first_non_whitespace<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
4161    // Some call sites invoke this right after `dd` / `<<` / `>>` etc
4162    // mutates the textarea content, so the migration buffer hasn't
4163    // seen the new lines OR new cursor yet. Mirror the full content
4164    // across before delegating, then push the result back so the
4165    // textarea reflects the resolved column too.
4166    ed.sync_buffer_content_from_textarea();
4167    crate::motions::move_first_non_blank(&mut ed.buffer);
4168    ed.push_buffer_cursor_to_textarea();
4169}
4170
4171fn find_char_on_line<H: crate::types::Host>(
4172    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4173    ch: char,
4174    forward: bool,
4175    till: bool,
4176) -> bool {
4177    let moved = crate::motions::find_char_on_line(&mut ed.buffer, ch, forward, till);
4178    if moved {
4179        ed.push_buffer_cursor_to_textarea();
4180    }
4181    moved
4182}
4183
4184fn matching_bracket<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) -> bool {
4185    let moved = crate::motions::match_bracket(&mut ed.buffer);
4186    if moved {
4187        ed.push_buffer_cursor_to_textarea();
4188    }
4189    moved
4190}
4191
4192/// `[(` / `])` / `[{` / `]}` — move to the `count`-th previous (`forward =
4193/// false`) / next (`forward = true`) unmatched bracket of the kind given by
4194/// `open` (`(` or `{`). Balanced inner pairs are skipped via a depth counter.
4195fn goto_unmatched_bracket<H: crate::types::Host>(
4196    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4197    forward: bool,
4198    open: char,
4199    count: usize,
4200) {
4201    let close = match open {
4202        '(' => ')',
4203        '{' => '}',
4204        _ => return,
4205    };
4206    let cursor = buf_cursor_pos(&ed.buffer);
4207    let rows = buf_row_count(&ed.buffer);
4208    let target = count.max(1);
4209    let mut found = 0usize;
4210    let mut depth = 0i32;
4211
4212    if forward {
4213        let mut r = cursor.row;
4214        let mut from_col = cursor.col + 1;
4215        while r < rows {
4216            let line: Vec<char> = buf_line(&ed.buffer, r)
4217                .unwrap_or_default()
4218                .chars()
4219                .collect();
4220            let mut ci = from_col;
4221            while ci < line.len() {
4222                let ch = line[ci];
4223                if ch == open {
4224                    depth += 1;
4225                } else if ch == close {
4226                    if depth == 0 {
4227                        found += 1;
4228                        if found == target {
4229                            buf_set_cursor_rc(&mut ed.buffer, r, ci);
4230                            ed.push_buffer_cursor_to_textarea();
4231                            return;
4232                        }
4233                    } else {
4234                        depth -= 1;
4235                    }
4236                }
4237                ci += 1;
4238            }
4239            r += 1;
4240            from_col = 0;
4241        }
4242    } else {
4243        let mut r = cursor.row as isize;
4244        // First row scans from the column left of the cursor; earlier rows from
4245        // their last column (`isize::MAX` clamps to `len - 1`).
4246        let mut from_col = cursor.col as isize - 1;
4247        while r >= 0 {
4248            let line: Vec<char> = buf_line(&ed.buffer, r as usize)
4249                .unwrap_or_default()
4250                .chars()
4251                .collect();
4252            let mut ci = from_col.min(line.len() as isize - 1);
4253            while ci >= 0 {
4254                let ch = line[ci as usize];
4255                if ch == close {
4256                    depth += 1;
4257                } else if ch == open {
4258                    if depth == 0 {
4259                        found += 1;
4260                        if found == target {
4261                            buf_set_cursor_rc(&mut ed.buffer, r as usize, ci as usize);
4262                            ed.push_buffer_cursor_to_textarea();
4263                            return;
4264                        }
4265                    } else {
4266                        depth -= 1;
4267                    }
4268                }
4269                ci -= 1;
4270            }
4271            r -= 1;
4272            from_col = isize::MAX;
4273        }
4274    }
4275}
4276
4277fn word_at_cursor_search<H: crate::types::Host>(
4278    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4279    forward: bool,
4280    whole_word: bool,
4281    count: usize,
4282) {
4283    let (row, col) = ed.cursor();
4284    let line: String = buf_line(&ed.buffer, row).unwrap_or_default();
4285    let chars: Vec<char> = line.chars().collect();
4286    if chars.is_empty() {
4287        return;
4288    }
4289    // Expand around cursor to a word boundary.
4290    let spec = ed.settings().iskeyword.clone();
4291    let is_word = |c: char| is_keyword_char(c, &spec);
4292    let mut start = col.min(chars.len().saturating_sub(1));
4293    while start > 0 && is_word(chars[start - 1]) {
4294        start -= 1;
4295    }
4296    let mut end = start;
4297    while end < chars.len() && is_word(chars[end]) {
4298        end += 1;
4299    }
4300    if end <= start {
4301        return;
4302    }
4303    let word: String = chars[start..end].iter().collect();
4304    let escaped = regex_escape(&word);
4305    let pattern = if whole_word {
4306        format!(r"\b{escaped}\b")
4307    } else {
4308        escaped
4309    };
4310    ed.push_search_pattern(&pattern);
4311    if ed.search_state().pattern.is_none() {
4312        return;
4313    }
4314    // Remember the query so `n` / `N` keep working after the jump.
4315    ed.vim.last_search = Some(pattern);
4316    ed.vim.last_search_forward = forward;
4317    for _ in 0..count.max(1) {
4318        if forward {
4319            ed.search_advance_forward(true);
4320        } else {
4321            ed.search_advance_backward(true);
4322        }
4323    }
4324    ed.push_buffer_cursor_to_textarea();
4325}
4326
4327fn regex_escape(s: &str) -> String {
4328    let mut out = String::with_capacity(s.len());
4329    for c in s.chars() {
4330        if matches!(
4331            c,
4332            '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\'
4333        ) {
4334            out.push('\\');
4335        }
4336        out.push(c);
4337    }
4338    out
4339}
4340
4341// ─── Operator application ──────────────────────────────────────────────────
4342
4343/// Public(crate) entry: apply operator over the motion identified by a raw
4344/// char key. Called by `Editor::apply_op_motion` (the public controller API)
4345/// so the hjkl-vim pending-state reducer can dispatch `ApplyOpMotion` without
4346/// re-entering the FSM.
4347///
4348/// Applies standard vim quirks:
4349/// - `cw` / `cW` → `ce` / `cE`
4350/// - `FindRepeat` → resolves against `last_find`
4351/// - Updates `last_find` and `last_change` per existing conventions.
4352///
4353/// No-op when `motion_key` does not produce a known motion.
4354pub(crate) fn apply_op_motion_key<H: crate::types::Host>(
4355    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4356    op: Operator,
4357    motion_key: char,
4358    total_count: usize,
4359) {
4360    let input = Input {
4361        key: Key::Char(motion_key),
4362        ctrl: false,
4363        alt: false,
4364        shift: false,
4365    };
4366    let Some(motion) = parse_motion(&input) else {
4367        return;
4368    };
4369    let motion = match motion {
4370        Motion::FindRepeat { reverse } => match ed.vim.last_find {
4371            Some((ch, forward, till)) => Motion::Find {
4372                ch,
4373                forward: if reverse { !forward } else { forward },
4374                till,
4375            },
4376            None => return,
4377        },
4378        // Vim quirk: `cw` / `cW` → `ce` / `cE`.
4379        Motion::WordFwd if op == Operator::Change => Motion::WordEnd,
4380        Motion::BigWordFwd if op == Operator::Change => Motion::BigWordEnd,
4381        m => m,
4382    };
4383    apply_op_with_motion(ed, op, &motion, total_count);
4384    if let Motion::Find { ch, forward, till } = &motion {
4385        ed.vim.last_find = Some((*ch, *forward, *till));
4386    }
4387    if !ed.vim.replaying && op_is_change(op) {
4388        ed.vim.last_change = Some(LastChange::OpMotion {
4389            op,
4390            motion,
4391            count: total_count,
4392            inserted: None,
4393        });
4394    }
4395}
4396
4397/// Public(crate) entry: apply doubled-letter line op (`dd`/`yy`/`cc`/`>>`/`<<`/`gcc`).
4398/// Called by `Editor::apply_op_double` (the public controller API).
4399pub(crate) fn apply_op_double<H: crate::types::Host>(
4400    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4401    op: Operator,
4402    total_count: usize,
4403) {
4404    if op == Operator::Comment {
4405        // `gcc` / `{N}gcc` — toggle comment on `total_count` lines starting at cursor.
4406        let row = buf_cursor_pos(&ed.buffer).row;
4407        let end_row = (row + total_count.max(1) - 1).min(ed.buffer.row_count().saturating_sub(1));
4408        ed.toggle_comment_range(row, end_row);
4409        ed.vim.mode = Mode::Normal;
4410        if !ed.vim.replaying {
4411            ed.vim.last_change = Some(LastChange::LineOp {
4412                op,
4413                count: total_count,
4414                inserted: None,
4415            });
4416        }
4417        return;
4418    }
4419    execute_line_op(ed, op, total_count);
4420    if !ed.vim.replaying {
4421        ed.vim.last_change = Some(LastChange::LineOp {
4422            op,
4423            count: total_count,
4424            inserted: None,
4425        });
4426    }
4427}
4428
4429/// Compute the `gn` / `gN` target match as a `(start, end_inclusive)` pair.
4430/// When the cursor sits inside a match, that match is the target; otherwise the
4431/// next match (forward) or previous match (backward) is used. Returns `None`
4432/// when there is no pattern or no match remains.
4433fn gn_find_range<H: crate::types::Host>(
4434    ed: &Editor<hjkl_buffer::Buffer, H>,
4435    re: &regex::Regex,
4436    forward: bool,
4437) -> Option<(crate::types::Pos, crate::types::Pos)> {
4438    use crate::types::{Cursor, Pos, Search};
4439    let cursor = Cursor::cursor(&ed.buffer);
4440    let contains =
4441        Search::find_prev(&ed.buffer, cursor, re).filter(|m| m.start <= cursor && cursor < m.end);
4442    let range = if let Some(m) = contains {
4443        m
4444    } else if forward {
4445        Search::find_next(&ed.buffer, cursor, re)?
4446    } else {
4447        Search::find_prev(&ed.buffer, cursor, re)?
4448    };
4449    let end_incl = if range.end.col > 0 {
4450        Pos::new(range.end.line, range.end.col - 1)
4451    } else {
4452        range.end
4453    };
4454    Some((range.start, end_incl))
4455}
4456
4457/// `gn` / `gN` — operate on (or select) the search match. `op = None` enters
4458/// Visual mode with the match selected; `Some(op)` applies the operator to the
4459/// match as a charwise inclusive range. Records `LastChange::GnOp` so `cgn` /
4460/// `dgn` are `.`-repeatable.
4461pub(crate) fn gn_operate<H: crate::types::Host>(
4462    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4463    op: Option<Operator>,
4464    forward: bool,
4465    count: usize,
4466) {
4467    use crate::types::{Cursor, Pos};
4468    // Make sure the compiled pattern reflects the last `/` or `*` search.
4469    if let Some(p) = ed.vim.last_search.clone() {
4470        ed.push_search_pattern(&p);
4471    }
4472    let Some(re) = ed.search_state().pattern.clone() else {
4473        return;
4474    };
4475    ed.sync_buffer_content_from_textarea();
4476
4477    let Some(mut range) = gn_find_range(ed, &re, forward) else {
4478        return;
4479    };
4480    // `[count]gn` walks to the count-th match.
4481    for _ in 1..count.max(1) {
4482        let past = Pos::new(range.1.line, range.1.col + 1);
4483        Cursor::set_cursor(&mut ed.buffer, past);
4484        match gn_find_range(ed, &re, forward) {
4485            Some(r) => range = r,
4486            None => break,
4487        }
4488    }
4489    let start_t = (range.0.line as usize, range.0.col as usize);
4490    let end_t = (range.1.line as usize, range.1.col as usize);
4491
4492    match op {
4493        None => {
4494            // Bare `gn` — select the match in Visual mode.
4495            ed.vim.visual_anchor = start_t;
4496            buf_set_cursor_rc(&mut ed.buffer, end_t.0, end_t.1);
4497            ed.vim.mode = Mode::Visual;
4498            ed.vim.current_mode = crate::VimMode::Visual;
4499            ed.push_buffer_cursor_to_textarea();
4500        }
4501        Some(Operator::Delete) => {
4502            ed.push_undo();
4503            cut_vim_range(ed, start_t, end_t, RangeKind::Inclusive);
4504            // Deleting at the line end can leave the cursor one past the last
4505            // char; vim clamps it back onto the line.
4506            clamp_cursor_to_normal_mode(ed);
4507            ed.push_buffer_cursor_to_textarea();
4508            if !ed.vim.replaying {
4509                ed.vim.last_change = Some(LastChange::GnOp {
4510                    op: Operator::Delete,
4511                    forward,
4512                    inserted: None,
4513                });
4514            }
4515        }
4516        Some(Operator::Change) => {
4517            ed.push_undo();
4518            ed.vim.change_mark_start = Some(start_t);
4519            cut_vim_range(ed, start_t, end_t, RangeKind::Inclusive);
4520            if !ed.vim.replaying {
4521                ed.vim.last_change = Some(LastChange::GnOp {
4522                    op: Operator::Change,
4523                    forward,
4524                    inserted: None,
4525                });
4526            }
4527            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
4528        }
4529        Some(Operator::Yank) => {
4530            let text = read_vim_range(ed, start_t, end_t, RangeKind::Inclusive);
4531            if !text.is_empty() {
4532                ed.record_yank_to_host(text.clone());
4533                ed.record_yank(text, false);
4534            }
4535            buf_set_cursor_rc(&mut ed.buffer, start_t.0, start_t.1);
4536            ed.push_buffer_cursor_to_textarea();
4537        }
4538        Some(other @ (Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase)) => {
4539            // Case op over a gn match: apply as a charwise op over the
4540            // inclusive range.
4541            ed.push_undo();
4542            apply_case_op_to_selection(ed, other, start_t, end_t, RangeKind::Inclusive);
4543        }
4544        Some(_) => {}
4545    }
4546}
4547
4548/// Shared implementation: apply operator over a g-chord motion or case-op
4549/// linewise form. Called by `Editor::apply_op_g` (the public controller API)
4550/// so the hjkl-vim reducer can dispatch `ApplyOpG` without re-entering the FSM.
4551///
4552/// - If `op` is Uppercase/Lowercase/ToggleCase and `ch` matches the op's char
4553///   (`U`/`u`/`~`): executes the line op and updates `last_change`.
4554/// - `n` / `N` operate on the search match (`dgn` / `cgn`).
4555/// - Otherwise, maps `ch` to a motion (`g`→FileTop, `e`→WordEndBack,
4556///   `E`→BigWordEndBack, `j`→ScreenDown, `k`→ScreenUp) and applies. Unknown
4557///   chars are silently ignored (no-op), matching the engine FSM's behaviour.
4558pub(crate) fn apply_op_g_inner<H: crate::types::Host>(
4559    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4560    op: Operator,
4561    ch: char,
4562    total_count: usize,
4563) {
4564    // Case-op linewise form: `gUgU`, `gugu`, `g~g~`, `g?g?` — same effect as
4565    // `gUU` / `guu` / `g~~` / `g??`.
4566    if matches!(
4567        op,
4568        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13
4569    ) {
4570        let op_char = match op {
4571            Operator::Uppercase => 'U',
4572            Operator::Lowercase => 'u',
4573            Operator::ToggleCase => '~',
4574            Operator::Rot13 => '?',
4575            _ => unreachable!(),
4576        };
4577        if ch == op_char {
4578            execute_line_op(ed, op, total_count);
4579            if !ed.vim.replaying {
4580                ed.vim.last_change = Some(LastChange::LineOp {
4581                    op,
4582                    count: total_count,
4583                    inserted: None,
4584                });
4585            }
4586            return;
4587        }
4588    }
4589    // `dgn` / `cgn` / `ygn` (and `gN` forms) — operate on the search match.
4590    if ch == 'n' || ch == 'N' {
4591        gn_operate(ed, Some(op), ch == 'n', total_count);
4592        return;
4593    }
4594    let motion = match ch {
4595        'g' => Motion::FileTop,
4596        'e' => Motion::WordEndBack,
4597        'E' => Motion::BigWordEndBack,
4598        'j' => Motion::ScreenDown,
4599        'k' => Motion::ScreenUp,
4600        _ => return, // Unknown char — no-op.
4601    };
4602    apply_op_with_motion(ed, op, &motion, total_count);
4603    if !ed.vim.replaying && op_is_change(op) {
4604        ed.vim.last_change = Some(LastChange::OpMotion {
4605            op,
4606            motion,
4607            count: total_count,
4608            inserted: None,
4609        });
4610    }
4611}
4612
4613/// Public(crate) entry point for bare `g<x>`. Applies the g-chord effect
4614/// given the char `ch` and pre-captured `count`. Called by `Editor::after_g`
4615/// (the public controller API) so the hjkl-vim pending-state reducer can
4616/// dispatch `AfterGChord` without re-entering the FSM.
4617pub(crate) fn apply_after_g<H: crate::types::Host>(
4618    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4619    ch: char,
4620    count: usize,
4621) {
4622    match ch {
4623        'g' => {
4624            // gg — top / jump to line count.
4625            let pre = ed.cursor();
4626            if count > 1 {
4627                ed.jump_cursor(count - 1, 0);
4628            } else {
4629                ed.jump_cursor(0, 0);
4630            }
4631            move_first_non_whitespace(ed);
4632            // Update sticky_col to the first-non-blank column so j/k after
4633            // gg aim for the correct column per vim semantics.
4634            ed.sticky_col = Some(ed.cursor().1);
4635            if ed.cursor() != pre {
4636                ed.push_jump(pre);
4637            }
4638        }
4639        'e' => execute_motion(ed, Motion::WordEndBack, count),
4640        'E' => execute_motion(ed, Motion::BigWordEndBack, count),
4641        // `g_` — last non-blank on the line.
4642        '_' => execute_motion(ed, Motion::LastNonBlank, count),
4643        // `gM` — middle char column of the current line.
4644        'M' => execute_motion(ed, Motion::LineMiddle, count),
4645        // `gv` — re-enter the last visual selection.
4646        // Phase 6.6a: drive through the public Editor API.
4647        'v' => ed.reenter_last_visual(),
4648        // `gj` / `gk` — display-line down / up. Walks one screen
4649        // segment at a time under `:set wrap`; falls back to `j`/`k`
4650        // when wrap is off (Buffer::move_screen_* handles the branch).
4651        'j' => execute_motion(ed, Motion::ScreenDown, count),
4652        'k' => execute_motion(ed, Motion::ScreenUp, count),
4653        // Case operators: `gU` / `gu` / `g~`. Enter operator-pending
4654        // so the next input is treated as the motion / text object /
4655        // shorthand double (`gUU`, `guu`, `g~~`).
4656        'U' => {
4657            ed.vim.pending = Pending::Op {
4658                op: Operator::Uppercase,
4659                count1: count,
4660            };
4661        }
4662        'u' => {
4663            ed.vim.pending = Pending::Op {
4664                op: Operator::Lowercase,
4665                count1: count,
4666            };
4667        }
4668        '~' => {
4669            ed.vim.pending = Pending::Op {
4670                op: Operator::ToggleCase,
4671                count1: count,
4672            };
4673        }
4674        '?' => {
4675            // `g?{motion}` — ROT13 operator (`g??` / `g?g?` doubled).
4676            ed.vim.pending = Pending::Op {
4677                op: Operator::Rot13,
4678                count1: count,
4679            };
4680        }
4681        'q' => {
4682            // `gq{motion}` — text reflow operator. Subsequent motion
4683            // / textobj rides the same operator pipeline.
4684            ed.vim.pending = Pending::Op {
4685                op: Operator::Reflow,
4686                count1: count,
4687            };
4688        }
4689        'w' => {
4690            // `gw{motion}` — same reflow as `gq` but cursor stays at
4691            // its pre-reflow position (clamped to new EOL if shorter).
4692            ed.vim.pending = Pending::Op {
4693                op: Operator::ReflowKeepCursor,
4694                count1: count,
4695            };
4696        }
4697        'J' => {
4698            // `gJ` — join line below without inserting a space. `[count]gJ`
4699            // joins `count` lines (`count - 1` joins), like `J`.
4700            let joins = count.max(2) - 1;
4701            for _ in 0..joins {
4702                ed.push_undo();
4703                join_line_raw(ed);
4704            }
4705            if !ed.vim.replaying {
4706                ed.vim.last_change = Some(LastChange::JoinLine { count: joins });
4707            }
4708        }
4709        'd' => {
4710            // `gd` — goto definition. hjkl-engine doesn't run an LSP
4711            // itself; raise an intent the host drains and routes to
4712            // `sqls`. The cursor stays put here — the host moves it
4713            // once it has the target location.
4714            ed.pending_lsp = Some(crate::editor::LspIntent::GotoDefinition);
4715        }
4716        // `gi` — go to last-insert position and re-enter insert mode.
4717        // Matches vim's `:h gi`: moves to the `'^` mark position (the
4718        // cursor where insert mode was last active, before Esc step-back)
4719        // and enters insert mode there.
4720        'i' => {
4721            if let Some((row, col)) = ed.vim.last_insert_pos {
4722                ed.jump_cursor(row, col);
4723            }
4724            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::I));
4725        }
4726        // `gc` — enter operator-pending for the comment-toggle operator.
4727        // `gcc` (doubled 'c') is the line-wise form; `gc{motion}` is the
4728        // motion form. The operator is Comment — the app layer (or the
4729        // doubled-char path in handle_after_op) calls toggle_comment_range.
4730        'c' => {
4731            ed.vim.pending = Pending::Op {
4732                op: Operator::Comment,
4733                count1: count,
4734            };
4735        }
4736        // `gp` / `gP` — paste like `p`/`P` but leave the cursor just after
4737        // the pasted text.
4738        'p' => paste_bridge(ed, false, count.max(1), true, false),
4739        'P' => paste_bridge(ed, true, count.max(1), true, false),
4740        // `gn` / `gN` — select the next / previous search match in Visual mode.
4741        'n' => gn_operate(ed, None, true, count.max(1)),
4742        'N' => gn_operate(ed, None, false, count.max(1)),
4743        // `g;` / `g,` — walk the change list. `g;` toward older
4744        // entries, `g,` toward newer.
4745        ';' => walk_change_list(ed, -1, count.max(1)),
4746        ',' => walk_change_list(ed, 1, count.max(1)),
4747        // `g*` / `g#` — like `*` / `#` but match substrings (no `\b`
4748        // boundary anchors), so the cursor on `foo` finds it inside
4749        // `foobar` too.
4750        '*' => execute_motion(
4751            ed,
4752            Motion::WordAtCursor {
4753                forward: true,
4754                whole_word: false,
4755            },
4756            count,
4757        ),
4758        '#' => execute_motion(
4759            ed,
4760            Motion::WordAtCursor {
4761                forward: false,
4762                whole_word: false,
4763            },
4764            count,
4765        ),
4766        // `g&` — repeat last `:s` over the whole buffer (1,$), keeping all
4767        // original flags. Equivalent to `:%s//~/&` in vim.
4768        '&' => {
4769            let cmd = match ed.vim.last_substitute.clone() {
4770                Some(c) => c,
4771                None => {
4772                    // No prior substitute — mirror the `:&` error path; do
4773                    // nothing to the buffer (the host's status line will show
4774                    // the pending error if wired; for headless / test hosts
4775                    // we simply return silently).
4776                    return;
4777                }
4778            };
4779            let last_row = buf_row_count(&ed.buffer).saturating_sub(1) as u32;
4780            let r = 0u32..=last_row;
4781            // apply_substitute moves cursor to last changed line and pushes
4782            // one undo snapshot — same semantics as `:&&` / `:%s//~/&`.
4783            let _ = crate::substitute::apply_substitute(ed, &cmd, r);
4784            // Update stored substitute so subsequent `g&` sees the same cmd.
4785            // (apply_substitute doesn't call set_last_substitute itself.)
4786            ed.vim.last_substitute = Some(cmd);
4787        }
4788        _ => {}
4789    }
4790}
4791
4792/// Normal-mode `&` — repeat the last `:s` on the current line, dropping the
4793/// previous flags (vim: `&` ≡ `:s` with no flags). `g&` keeps flags + whole
4794/// buffer; this is the single-line, flag-less form.
4795pub(crate) fn ampersand_repeat<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
4796    let Some(mut cmd) = ed.vim.last_substitute.clone() else {
4797        return;
4798    };
4799    cmd.flags = crate::substitute::SubstFlags::default();
4800    let row = buf_cursor_pos(&ed.buffer).row as u32;
4801    let _ = crate::substitute::apply_substitute(ed, &cmd, row..=row);
4802}
4803
4804/// Public(crate) entry point for bare `z<x>`. Applies the z-chord effect
4805/// given the char `ch` and pre-captured `count`. Called by `Editor::after_z`
4806/// (the public controller API) so the hjkl-vim pending-state reducer can
4807/// dispatch `AfterZChord` without re-entering the engine FSM.
4808pub(crate) fn apply_after_z<H: crate::types::Host>(
4809    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4810    ch: char,
4811    count: usize,
4812) {
4813    use crate::editor::CursorScrollTarget;
4814    let row = ed.cursor().0;
4815    match ch {
4816        'z' => {
4817            ed.scroll_cursor_to(CursorScrollTarget::Center);
4818            ed.vim.viewport_pinned = true;
4819        }
4820        't' => {
4821            ed.scroll_cursor_to(CursorScrollTarget::Top);
4822            ed.vim.viewport_pinned = true;
4823        }
4824        'b' => {
4825            ed.scroll_cursor_to(CursorScrollTarget::Bottom);
4826            ed.vim.viewport_pinned = true;
4827        }
4828        // Folds — operate on the fold under the cursor (or the
4829        // whole buffer for `R` / `M`). Routed through
4830        // [`Editor::apply_fold_op`] (0.0.38 Patch C-δ.4) so the host
4831        // can observe / veto each op via [`Editor::take_fold_ops`].
4832        'o' => {
4833            ed.apply_fold_op(crate::types::FoldOp::OpenAt(row));
4834        }
4835        'c' => {
4836            ed.apply_fold_op(crate::types::FoldOp::CloseAt(row));
4837        }
4838        'a' => {
4839            ed.apply_fold_op(crate::types::FoldOp::ToggleAt(row));
4840        }
4841        'R' => {
4842            ed.apply_fold_op(crate::types::FoldOp::OpenAll);
4843        }
4844        'M' => {
4845            ed.apply_fold_op(crate::types::FoldOp::CloseAll);
4846        }
4847        'E' => {
4848            ed.apply_fold_op(crate::types::FoldOp::ClearAll);
4849        }
4850        'd' => {
4851            ed.apply_fold_op(crate::types::FoldOp::RemoveAt(row));
4852        }
4853        'f' => {
4854            if matches!(
4855                ed.vim.mode,
4856                Mode::Visual | Mode::VisualLine | Mode::VisualBlock
4857            ) {
4858                // `zf` over a Visual selection creates a fold spanning
4859                // anchor → cursor.
4860                let anchor_row = match ed.vim.mode {
4861                    Mode::VisualLine => ed.vim.visual_line_anchor,
4862                    Mode::VisualBlock => ed.vim.block_anchor.0,
4863                    _ => ed.vim.visual_anchor.0,
4864                };
4865                let cur = ed.cursor().0;
4866                let top = anchor_row.min(cur);
4867                let bot = anchor_row.max(cur);
4868                ed.apply_fold_op(crate::types::FoldOp::Add {
4869                    start_row: top,
4870                    end_row: bot,
4871                    closed: true,
4872                });
4873                ed.vim.mode = Mode::Normal;
4874            } else {
4875                // `zf{motion}` / `zf{textobj}` — route through the
4876                // operator pipeline. `Operator::Fold` reuses every
4877                // motion / text-object / `g`-prefix branch the other
4878                // operators get.
4879                ed.vim.pending = Pending::Op {
4880                    op: Operator::Fold,
4881                    count1: count,
4882                };
4883            }
4884        }
4885        _ => {}
4886    }
4887}
4888
4889/// Public(crate) entry point for bare `f<x>` / `F<x>` / `t<x>` / `T<x>`.
4890/// Applies the motion and records `last_find` for `;` / `,` repeat.
4891/// Called by `Editor::find_char` (the public controller API) so the
4892/// hjkl-vim pending-state reducer can dispatch `FindChar` without
4893/// re-entering the FSM.
4894pub(crate) fn apply_find_char<H: crate::types::Host>(
4895    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4896    ch: char,
4897    forward: bool,
4898    till: bool,
4899    count: usize,
4900) {
4901    execute_motion(ed, Motion::Find { ch, forward, till }, count.max(1));
4902    ed.vim.last_find = Some((ch, forward, till));
4903    ed.vim.last_horizontal_motion = LastHorizontalMotion::FindChar;
4904}
4905
4906// ─── Sneak motion ──────────────────────────────────────────────────────────
4907
4908/// Scan the buffer from the current cursor position for the `count`-th
4909/// occurrence of the two-char digraph `(c1, c2)`.
4910///
4911/// - `forward=true` → scan downward (rows) and rightward (cols) past cursor.
4912/// - `forward=false` → scan upward and leftward.
4913///
4914/// When a match is found the cursor jumps to the first char of the digraph.
4915/// `last_sneak` and `last_horizontal_motion` are updated so `;`/`,` repeat.
4916/// No-op (cursor unchanged) when no match exists.
4917pub(crate) fn apply_sneak<H: crate::types::Host>(
4918    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4919    c1: char,
4920    c2: char,
4921    forward: bool,
4922    count: usize,
4923) {
4924    let count = count.max(1);
4925    let (start_row, start_col) = ed.cursor();
4926    let row_count = buf_row_count(&ed.buffer);
4927
4928    let result = if forward {
4929        sneak_scan_forward(ed, start_row, start_col, c1, c2, count)
4930    } else {
4931        sneak_scan_backward(ed, start_row, start_col, c1, c2, count)
4932    };
4933
4934    if let Some((row, col)) = result {
4935        buf_set_cursor_rc(&mut ed.buffer, row, col);
4936        ed.push_buffer_cursor_to_textarea();
4937        let _ = row_count; // suppress unused-variable warning
4938    }
4939
4940    ed.vim.last_sneak = Some(((c1, c2), forward));
4941    ed.vim.last_horizontal_motion = LastHorizontalMotion::Sneak;
4942}
4943
4944/// Scan forward from `(start_row, start_col)` (exclusive — start right after
4945/// cursor) for the `count`-th occurrence of `c1+c2`.
4946fn sneak_scan_forward<H: crate::types::Host>(
4947    ed: &Editor<hjkl_buffer::Buffer, H>,
4948    start_row: usize,
4949    start_col: usize,
4950    c1: char,
4951    c2: char,
4952    count: usize,
4953) -> Option<(usize, usize)> {
4954    let row_count = buf_row_count(&ed.buffer);
4955    let mut hits = 0usize;
4956    for row in start_row..row_count {
4957        let line = buf_line(&ed.buffer, row).unwrap_or_default();
4958        let chars: Vec<char> = line.chars().collect();
4959        // On the start row begin scanning one past the current column.
4960        let col_start = if row == start_row { start_col + 1 } else { 0 };
4961        if col_start + 1 > chars.len() {
4962            continue;
4963        }
4964        for col in col_start..chars.len().saturating_sub(1) {
4965            if chars[col] == c1 && chars[col + 1] == c2 {
4966                hits += 1;
4967                if hits == count {
4968                    return Some((row, col));
4969                }
4970            }
4971        }
4972    }
4973    None
4974}
4975
4976/// Scan backward from `(start_row, start_col)` (exclusive — start left of
4977/// cursor) for the `count`-th occurrence of `c1+c2`.
4978fn sneak_scan_backward<H: crate::types::Host>(
4979    ed: &Editor<hjkl_buffer::Buffer, H>,
4980    start_row: usize,
4981    start_col: usize,
4982    c1: char,
4983    c2: char,
4984    count: usize,
4985) -> Option<(usize, usize)> {
4986    let row_count = buf_row_count(&ed.buffer);
4987    let mut hits = 0usize;
4988    // Iterate rows from start_row down to 0.
4989    let rows_to_scan = (0..row_count).rev().skip(row_count - start_row - 1);
4990    for row in rows_to_scan {
4991        let line = buf_line(&ed.buffer, row).unwrap_or_default();
4992        let chars: Vec<char> = line.chars().collect();
4993        // On the start row end scanning one before the current column.
4994        let col_end = if row == start_row {
4995            start_col.saturating_sub(1)
4996        } else if chars.is_empty() {
4997            continue;
4998        } else {
4999            chars.len().saturating_sub(1)
5000        };
5001        if col_end == 0 {
5002            continue;
5003        }
5004        // Scan cols right-to-left from col_end-1 so we match c1 at col, c2 at col+1.
5005        for col in (0..col_end).rev() {
5006            if col + 1 < chars.len() && chars[col] == c1 && chars[col + 1] == c2 {
5007                hits += 1;
5008                if hits == count {
5009                    return Some((row, col));
5010                }
5011            }
5012        }
5013    }
5014    None
5015}
5016
5017/// Apply `op` over the sneak digraph range. Charwise exclusive from cursor up
5018/// to (but not including) the first char of the first match. This matches
5019/// vim-sneak's default `<Plug>Sneak_s` operator-pending behavior.
5020///
5021/// Example: buffer `"foo ab bar\n"`, cursor col 0, `dsab` → deletes `"foo "`
5022/// leaving `"ab bar\n"`.
5023pub(crate) fn apply_op_sneak<H: crate::types::Host>(
5024    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5025    op: Operator,
5026    c1: char,
5027    c2: char,
5028    forward: bool,
5029    total_count: usize,
5030) {
5031    let start = ed.cursor();
5032    let result = if forward {
5033        sneak_scan_forward(ed, start.0, start.1, c1, c2, total_count)
5034    } else {
5035        sneak_scan_backward(ed, start.0, start.1, c1, c2, total_count)
5036    };
5037    let Some(end) = result else {
5038        return;
5039    };
5040    // Charwise exclusive — land the virtual cursor at end, then use
5041    // Exclusive range kind (end position not included).
5042    ed.jump_cursor(end.0, end.1);
5043    let end_cur = ed.cursor();
5044    ed.jump_cursor(start.0, start.1);
5045    run_operator_over_range(ed, op, start, end_cur, RangeKind::Exclusive);
5046    ed.vim.last_sneak = Some(((c1, c2), forward));
5047    ed.vim.last_horizontal_motion = LastHorizontalMotion::Sneak;
5048    if !ed.vim.replaying && op_is_change(op) {
5049        // No dot-repeat motion variant for sneak ops (plugin behavior,
5050        // not vim-core); record as a Change/Delete line op as a
5051        // best-effort fallback so `.` at least does something.
5052    }
5053}
5054
5055/// Public(crate) entry: apply operator over a find motion (`df<x>` etc.).
5056/// Called by `Editor::apply_op_find` (the public controller API) so the
5057/// hjkl-vim `PendingState::OpFind` reducer can dispatch `ApplyOpFind` without
5058/// re-entering the FSM. `handle_op_find_target` now delegates here to avoid
5059/// logic duplication.
5060pub(crate) fn apply_op_find_motion<H: crate::types::Host>(
5061    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5062    op: Operator,
5063    ch: char,
5064    forward: bool,
5065    till: bool,
5066    total_count: usize,
5067) {
5068    let motion = Motion::Find { ch, forward, till };
5069    apply_op_with_motion(ed, op, &motion, total_count);
5070    ed.vim.last_find = Some((ch, forward, till));
5071    if !ed.vim.replaying && op_is_change(op) {
5072        ed.vim.last_change = Some(LastChange::OpMotion {
5073            op,
5074            motion,
5075            count: total_count,
5076            inserted: None,
5077        });
5078    }
5079}
5080
5081/// Shared implementation: map `ch` to `TextObject`, apply the operator, and
5082/// record `last_change`. Returns `false` when `ch` is not a known text-object
5083/// kind (caller should treat as a no-op). Called by `Editor::apply_op_text_obj`
5084/// (the public controller API) so hjkl-vim can dispatch without re-entering the FSM.
5085///
5086/// `_total_count` is accepted for API symmetry with `apply_op_find_motion` /
5087/// `apply_op_motion_key` but is currently unused — text objects don't repeat
5088/// in vim's current grammar. Kept for future-proofing.
5089pub(crate) fn apply_op_text_obj_inner<H: crate::types::Host>(
5090    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5091    op: Operator,
5092    ch: char,
5093    inner: bool,
5094    total_count: usize,
5095) -> bool {
5096    // `total_count` drives bracket text objects: `2di{` targets the Nth
5097    // enclosing pair. Non-bracket objects ignore it (vim does too).
5098    let obj = match ch {
5099        'w' => TextObject::Word { big: false },
5100        'W' => TextObject::Word { big: true },
5101        '"' | '\'' | '`' => TextObject::Quote(ch),
5102        '(' | ')' | 'b' => TextObject::Bracket('('),
5103        '[' | ']' => TextObject::Bracket('['),
5104        '{' | '}' | 'B' => TextObject::Bracket('{'),
5105        '<' | '>' => TextObject::Bracket('<'),
5106        'p' => TextObject::Paragraph,
5107        't' => TextObject::XmlTag,
5108        's' => TextObject::Sentence,
5109        _ => return false,
5110    };
5111    apply_op_with_text_object(ed, op, obj, inner, total_count.max(1));
5112    if !ed.vim.replaying && op_is_change(op) {
5113        ed.vim.last_change = Some(LastChange::OpTextObj {
5114            op,
5115            obj,
5116            inner,
5117            inserted: None,
5118        });
5119    }
5120    true
5121}
5122
5123/// Move `pos` back by one character, clamped to (0, 0).
5124pub(crate) fn retreat_one<H: crate::types::Host>(
5125    ed: &Editor<hjkl_buffer::Buffer, H>,
5126    pos: (usize, usize),
5127) -> (usize, usize) {
5128    let (r, c) = pos;
5129    if c > 0 {
5130        (r, c - 1)
5131    } else if r > 0 {
5132        let prev_len = buf_line_bytes(&ed.buffer, r - 1);
5133        (r - 1, prev_len)
5134    } else {
5135        (0, 0)
5136    }
5137}
5138
5139/// Variant of begin_insert that doesn't push_undo (caller already did).
5140fn begin_insert_noundo<H: crate::types::Host>(
5141    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5142    count: usize,
5143    reason: InsertReason,
5144) {
5145    let reason = if ed.vim.replaying {
5146        InsertReason::ReplayOnly
5147    } else {
5148        reason
5149    };
5150    let (row, col) = ed.cursor();
5151    ed.vim.insert_session = Some(InsertSession {
5152        count,
5153        row_min: row,
5154        row_max: row,
5155        before_rope: crate::types::Query::rope(&ed.buffer),
5156        reason,
5157        start_row: row,
5158        start_col: col,
5159    });
5160    ed.vim.mode = Mode::Insert;
5161    // Phase 6.3: keep current_mode in sync for callers that bypass step().
5162    ed.vim.current_mode = crate::VimMode::Insert;
5163    drop_blame_if_left_normal(ed);
5164}
5165
5166// ─── Operator × Motion application ─────────────────────────────────────────
5167
5168pub(crate) fn apply_op_with_motion<H: crate::types::Host>(
5169    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5170    op: Operator,
5171    motion: &Motion,
5172    count: usize,
5173) {
5174    let start = ed.cursor();
5175    // Tentatively apply motion to find the endpoint. Operator context
5176    // so `l` on the last char advances past-last (standard vim
5177    // exclusive-motion endpoint behaviour), enabling `dl` / `cl` /
5178    // `yl` to cover the final char.
5179    apply_motion_cursor_ctx(ed, motion, count, true);
5180    let end = ed.cursor();
5181    let kind = motion_kind(motion);
5182    // Restore cursor before selecting (so Yank leaves cursor at start).
5183    ed.jump_cursor(start.0, start.1);
5184
5185    // Comment is always linewise regardless of motion kind — toggle rows.
5186    if op == Operator::Comment {
5187        let top = start.0.min(end.0);
5188        let bot = start.0.max(end.0);
5189        ed.toggle_comment_range(top, bot);
5190        ed.vim.mode = Mode::Normal;
5191        return;
5192    }
5193
5194    run_operator_over_range(ed, op, start, end, kind);
5195}
5196
5197fn apply_op_with_text_object<H: crate::types::Host>(
5198    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5199    op: Operator,
5200    obj: TextObject,
5201    inner: bool,
5202    count: usize,
5203) {
5204    let Some((mut start, mut end, mut kind)) = text_object_range(ed, obj, inner, count) else {
5205        return;
5206    };
5207    // vim's exclusive-motion adjustment (`:h exclusive`), applied to the
5208    // OPERATOR form of an inner bracket object spanning multiple lines (the
5209    // visual form keeps the raw charwise region). When the exclusive end sits
5210    // in column 0, pull it back to the end of the previous line and make the
5211    // motion inclusive; if the start is at or before the first non-blank of its
5212    // line, promote to linewise. This is what makes `di{` on a contentful
5213    // multi-line block collapse to bare braces ("{\n}") and a clean block
5214    // delete its body linewise.
5215    if inner
5216        && matches!(obj, TextObject::Bracket(_))
5217        && kind == RangeKind::Exclusive
5218        && end.0 > start.0
5219        && end.1 == 0
5220    {
5221        let prev = end.0 - 1;
5222        let prev_len = buf_line_chars(&ed.buffer, prev);
5223        let fnb = buf_line(&ed.buffer, start.0)
5224            .unwrap_or_default()
5225            .chars()
5226            .take_while(|c| *c == ' ' || *c == '\t')
5227            .count();
5228        if start.1 <= fnb {
5229            start = (start.0, 0);
5230            end = (prev, prev_len);
5231            kind = RangeKind::Linewise;
5232        } else {
5233            end = (prev, prev_len.saturating_sub(1));
5234            kind = RangeKind::Inclusive;
5235        }
5236    }
5237    ed.jump_cursor(start.0, start.1);
5238    run_operator_over_range(ed, op, start, end, kind);
5239}
5240
5241fn motion_kind(motion: &Motion) -> RangeKind {
5242    match motion {
5243        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown => RangeKind::Linewise,
5244        Motion::FileTop | Motion::FileBottom => RangeKind::Linewise,
5245        Motion::ViewportTop | Motion::ViewportMiddle | Motion::ViewportBottom => {
5246            RangeKind::Linewise
5247        }
5248        Motion::WordEnd | Motion::BigWordEnd | Motion::WordEndBack | Motion::BigWordEndBack => {
5249            RangeKind::Inclusive
5250        }
5251        Motion::Find { .. } => RangeKind::Inclusive,
5252        Motion::MatchBracket => RangeKind::Inclusive,
5253        // `[(` / `])` etc. are exclusive: `d])` deletes up to but not including
5254        // the bracket; `d[(` deletes back to but not past the open bracket.
5255        Motion::UnmatchedBracket { .. } => RangeKind::Exclusive,
5256        // `$` now lands on the last char — operator ranges include it.
5257        Motion::LineEnd => RangeKind::Inclusive,
5258        // Linewise motions: +/-/_ land on the first non-blank of a line.
5259        Motion::FirstNonBlankNextLine
5260        | Motion::FirstNonBlankPrevLine
5261        | Motion::FirstNonBlankLine => RangeKind::Linewise,
5262        // [[/]]/[][/][ are charwise exclusive (land on the brace, brace excluded from operator).
5263        Motion::SectionBackward
5264        | Motion::SectionForward
5265        | Motion::SectionEndBackward
5266        | Motion::SectionEndForward => RangeKind::Exclusive,
5267        _ => RangeKind::Exclusive,
5268    }
5269}
5270
5271/// Linewise change of rows `[top_row, end_row]` (vim `cc`/`cj`/`Vc`/`cip`…).
5272///
5273/// Deletes the spanned lines, leaves one line carrying the first row's
5274/// leading whitespace (when `autoindent` is on), parks the cursor after
5275/// the indent, and enters insert mode. Records the full linewise payload
5276/// to the yank + delete registers and sets `change_mark_start` for the
5277/// `[`/`]` deferral. Calls `push_undo` internally — callers must NOT also
5278/// call it.
5279fn change_linewise_rows<H: crate::types::Host>(
5280    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5281    top_row: usize,
5282    end_row: usize,
5283) {
5284    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
5285    // Vim `:h '[`: stash change start for `]` deferral on insert-exit.
5286    ed.vim.change_mark_start = Some((top_row, 0));
5287    ed.push_undo();
5288    ed.sync_buffer_content_from_textarea();
5289    // Read the cut payload first so yank reflects every original line.
5290    let payload = read_vim_range(ed, (top_row, 0), (end_row, 0), RangeKind::Linewise);
5291    // Drop every row after the first (rows [top_row+1, end_row]).
5292    if end_row > top_row {
5293        ed.mutate_edit(Edit::DeleteRange {
5294            start: Position::new(top_row + 1, 0),
5295            end: Position::new(end_row, 0),
5296            kind: BufKind::Line,
5297        });
5298    }
5299    // Preserve the first row's leading whitespace when autoindent is on;
5300    // wipe the whole line content otherwise (cursor lands at col 0).
5301    let indent_chars = if ed.settings.autoindent {
5302        let line = hjkl_buffer::rope_line_str(&crate::types::Query::rope(&ed.buffer), top_row);
5303        line.chars().take_while(|c| *c == ' ' || *c == '\t').count()
5304    } else {
5305        0
5306    };
5307    let line_chars = buf_line_chars(&ed.buffer, top_row);
5308    if line_chars > indent_chars {
5309        ed.mutate_edit(Edit::DeleteRange {
5310            start: Position::new(top_row, indent_chars),
5311            end: Position::new(top_row, line_chars),
5312            kind: BufKind::Char,
5313        });
5314    }
5315    if !payload.is_empty() {
5316        ed.record_yank_to_host(payload.clone());
5317        ed.record_delete(payload, true);
5318    }
5319    buf_set_cursor_rc(&mut ed.buffer, top_row, indent_chars);
5320    ed.push_buffer_cursor_to_textarea();
5321    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
5322}
5323
5324fn run_operator_over_range<H: crate::types::Host>(
5325    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5326    op: Operator,
5327    start: (usize, usize),
5328    end: (usize, usize),
5329    kind: RangeKind,
5330) {
5331    let (top, bot) = order(start, end);
5332    // Charwise empty range (same position). For Delete/Yank there is nothing to
5333    // act on. For Change, vim still enters insert at that point — `ci(` on `()`
5334    // and `ci{` on a whitespace-only block both place the cursor inside and
5335    // start inserting without deleting anything.
5336    if top == bot && !matches!(kind, RangeKind::Linewise) {
5337        if op == Operator::Change {
5338            ed.vim.change_mark_start = Some(top);
5339            ed.push_undo();
5340            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
5341        }
5342        return;
5343    }
5344
5345    match op {
5346        Operator::Yank => {
5347            let text = read_vim_range(ed, top, bot, kind);
5348            if !text.is_empty() {
5349                ed.record_yank_to_host(text.clone());
5350                ed.record_yank(text, matches!(kind, RangeKind::Linewise));
5351            }
5352            // Vim `:h '[` / `:h ']`: after a yank `[` = first yanked char,
5353            // `]` = last yanked char. Mode-aware: linewise snaps to line
5354            // edges; charwise uses the actual inclusive endpoint.
5355            let rbr = match kind {
5356                RangeKind::Linewise => {
5357                    let last_col = buf_line_chars(&ed.buffer, bot.0).saturating_sub(1);
5358                    (bot.0, last_col)
5359                }
5360                RangeKind::Inclusive => (bot.0, bot.1),
5361                RangeKind::Exclusive => (bot.0, bot.1.saturating_sub(1)),
5362            };
5363            ed.set_mark('[', top);
5364            ed.set_mark(']', rbr);
5365            buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
5366            ed.push_buffer_cursor_to_textarea();
5367        }
5368        Operator::Delete => {
5369            ed.push_undo();
5370            cut_vim_range(ed, top, bot, kind);
5371            // After a charwise / inclusive delete the buffer cursor is
5372            // placed at `start` by the edit path. In Normal mode the
5373            // cursor max col is `line_len - 1`; clamp it here so e.g.
5374            // `d$` doesn't leave the cursor one past the new line end.
5375            if !matches!(kind, RangeKind::Linewise) {
5376                clamp_cursor_to_normal_mode(ed);
5377            }
5378            ed.vim.mode = Mode::Normal;
5379            // Vim `:h '[` / `:h ']`: after a delete both marks park at
5380            // the cursor position where the deletion collapsed (the join
5381            // point). Set after the cut and clamp so the position is final.
5382            let pos = ed.cursor();
5383            ed.set_mark('[', pos);
5384            ed.set_mark(']', pos);
5385        }
5386        Operator::Change => {
5387            // Vim `:h '[`: `[` is set to the start of the changed range
5388            // before the cut. `]` is deferred to insert-exit (AfterChange
5389            // path in finish_insert_session) where the cursor sits on the
5390            // last inserted char.
5391            if matches!(kind, RangeKind::Linewise) {
5392                // Linewise change (`cj`/`ck`/`cip`/`cap`/…): preserve the
5393                // first line's indent and leave exactly one row open for
5394                // insert. The helper handles push_undo + insert entry.
5395                change_linewise_rows(ed, top.0, bot.0);
5396            } else {
5397                // Charwise change: cut the range and enter insert.
5398                ed.vim.change_mark_start = Some(top);
5399                ed.push_undo();
5400                cut_vim_range(ed, top, bot, kind);
5401                begin_insert_noundo(ed, 1, InsertReason::AfterChange);
5402            }
5403        }
5404        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {
5405            apply_case_op_to_selection(ed, op, top, bot, kind);
5406        }
5407        Operator::Indent | Operator::Outdent => {
5408            // Indent / outdent are always linewise even when triggered
5409            // by a char-wise motion (e.g. `>w` indents the whole line).
5410            ed.push_undo();
5411            if op == Operator::Indent {
5412                indent_rows(ed, top.0, bot.0, 1);
5413            } else {
5414                outdent_rows(ed, top.0, bot.0, 1);
5415            }
5416            ed.vim.mode = Mode::Normal;
5417        }
5418        Operator::Fold => {
5419            // Always linewise — fold the spanned rows regardless of the
5420            // motion's natural kind. Cursor lands on `top.0` to mirror
5421            // the visual `zf` path.
5422            if bot.0 >= top.0 {
5423                ed.apply_fold_op(crate::types::FoldOp::Add {
5424                    start_row: top.0,
5425                    end_row: bot.0,
5426                    closed: true,
5427                });
5428            }
5429            buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
5430            ed.push_buffer_cursor_to_textarea();
5431            ed.vim.mode = Mode::Normal;
5432        }
5433        Operator::Reflow => {
5434            ed.push_undo();
5435            reflow_rows(ed, top.0, bot.0);
5436            ed.vim.mode = Mode::Normal;
5437        }
5438        Operator::ReflowKeepCursor => {
5439            // `gw{motion}` — reflow like `gq` but restore the cursor to the
5440            // character it was on before the reflow (vim's gw behaviour).
5441            let saved = ed.cursor();
5442            ed.push_undo();
5443            let (before, after) = reflow_rows_keep_cursor(ed, top.0, bot.0);
5444            let (new_row, new_col) = reflow_keep_cursor(top.0, saved.0, saved.1, &before, &after);
5445            buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
5446            ed.push_buffer_cursor_to_textarea();
5447            ed.sticky_col = Some(new_col);
5448            ed.vim.mode = Mode::Normal;
5449        }
5450        Operator::AutoIndent => {
5451            // Always linewise — like Indent/Outdent.
5452            ed.push_undo();
5453            auto_indent_rows(ed, top.0, bot.0);
5454            ed.vim.mode = Mode::Normal;
5455        }
5456        Operator::Filter => {
5457            // Filter is not dispatched through run_operator_over_range.
5458            // The app calls Editor::filter_range directly with a command string.
5459            // Reaching this arm means a caller invoked run_operator_over_range
5460            // with Operator::Filter by mistake — silently no-op.
5461        }
5462        Operator::Comment => {
5463            // Comment is dispatched through Editor::toggle_comment_range.
5464            // Reaching this arm is a caller mistake — silently no-op.
5465        }
5466    }
5467}
5468
5469// ─── Phase 4a pub range-mutation bridges ───────────────────────────────────
5470//
5471// These are `pub(crate)` entry points called by the five new pub methods on
5472// `Editor` (`delete_range`, `yank_range`, `change_range`, `indent_range`,
5473// `case_range`). They set `pending_register` from the caller-supplied char
5474// before delegating to the existing internal helpers so register semantics
5475// (unnamed `"`, named `"a`–`"z`, delete ring) are honoured exactly as in the
5476// FSM path.
5477//
5478// Do NOT call `run_operator_over_range` for Indent/Outdent or the three case
5479// operators — those share the FSM path but have dedicated parameter shapes
5480// (signed count, Operator-as-CaseOp) that map more cleanly to their own
5481// helpers.
5482
5483/// Delete the range `[start, end)` (interpretation determined by `kind`) and
5484/// stash the deleted text in `register`. `'"'` is the unnamed register.
5485pub(crate) fn delete_range_bridge<H: crate::types::Host>(
5486    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5487    start: (usize, usize),
5488    end: (usize, usize),
5489    kind: RangeKind,
5490    register: char,
5491) {
5492    ed.vim.pending_register = Some(register);
5493    run_operator_over_range(ed, Operator::Delete, start, end, kind);
5494}
5495
5496/// Yank (copy) the range `[start, end)` into `register` without mutating the
5497/// buffer. `'"'` is the unnamed register.
5498pub(crate) fn yank_range_bridge<H: crate::types::Host>(
5499    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5500    start: (usize, usize),
5501    end: (usize, usize),
5502    kind: RangeKind,
5503    register: char,
5504) {
5505    ed.vim.pending_register = Some(register);
5506    run_operator_over_range(ed, Operator::Yank, start, end, kind);
5507}
5508
5509/// Delete the range `[start, end)` and enter Insert mode (vim `c` operator).
5510/// The deleted text is stashed in `register`. Mode transitions to Insert on
5511/// return; the caller must not issue further normal-mode ops until the insert
5512/// session ends.
5513pub(crate) fn change_range_bridge<H: crate::types::Host>(
5514    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5515    start: (usize, usize),
5516    end: (usize, usize),
5517    kind: RangeKind,
5518    register: char,
5519) {
5520    ed.vim.pending_register = Some(register);
5521    run_operator_over_range(ed, Operator::Change, start, end, kind);
5522}
5523
5524/// Indent (`count > 0`) or outdent (`count < 0`) the row span `[start.0,
5525/// end.0]`. `shiftwidth` overrides the editor's `settings().shiftwidth` for
5526/// this call; pass `0` to use the editor setting. The column parts of `start`
5527/// / `end` are ignored — indent is always linewise.
5528pub(crate) fn indent_range_bridge<H: crate::types::Host>(
5529    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5530    start: (usize, usize),
5531    end: (usize, usize),
5532    count: i32,
5533    shiftwidth: u32,
5534) {
5535    if count == 0 {
5536        return;
5537    }
5538    let (top_row, bot_row) = if start.0 <= end.0 {
5539        (start.0, end.0)
5540    } else {
5541        (end.0, start.0)
5542    };
5543    // Temporarily override shiftwidth when the caller provides one.
5544    let original_sw = ed.settings().shiftwidth;
5545    if shiftwidth > 0 {
5546        ed.settings_mut().shiftwidth = shiftwidth as usize;
5547    }
5548    ed.push_undo();
5549    let abs_count = count.unsigned_abs() as usize;
5550    if count > 0 {
5551        indent_rows(ed, top_row, bot_row, abs_count);
5552    } else {
5553        outdent_rows(ed, top_row, bot_row, abs_count);
5554    }
5555    if shiftwidth > 0 {
5556        ed.settings_mut().shiftwidth = original_sw;
5557    }
5558    ed.vim.mode = Mode::Normal;
5559}
5560
5561/// Apply a case transformation (`Uppercase` / `Lowercase` / `ToggleCase`) to
5562/// the range `[start, end)`. Only the three case `Operator` variants are valid;
5563/// other variants are silently ignored (no-op).
5564pub(crate) fn case_range_bridge<H: crate::types::Host>(
5565    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5566    start: (usize, usize),
5567    end: (usize, usize),
5568    kind: RangeKind,
5569    op: Operator,
5570) {
5571    match op {
5572        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {}
5573        _ => return,
5574    }
5575    let (top, bot) = order(start, end);
5576    apply_case_op_to_selection(ed, op, top, bot, kind);
5577}
5578
5579// ─── Phase 4e pub block-shape range-mutation bridges ───────────────────────
5580//
5581// These are `pub(crate)` entry points called by the four new pub methods on
5582// `Editor` (`delete_block`, `yank_block`, `change_block`, `indent_block`).
5583// They set `pending_register` from the caller-supplied char then delegate to
5584// `apply_block_operator` (after temporarily installing the 4-corner block as
5585// the engine's virtual VisualBlock selection). The editor's VisualBlock state
5586// fields (`block_anchor`, `block_vcol`) are overwritten, the op fires, then
5587// the fields are restored to their pre-call values. This ensures the engine's
5588// register / undo / mode semantics are exercised without requiring the caller
5589// to already be in VisualBlock mode.
5590//
5591// `indent_block` is a separate helper — it does not use `apply_block_operator`
5592// because indent/outdent are always linewise for blocks (vim behaviour).
5593
5594/// Delete a rectangular VisualBlock selection. `top_row`/`bot_row` are
5595/// inclusive line bounds; `left_col`/`right_col` are inclusive char-column
5596/// bounds. Short lines that don't reach `right_col` lose only the chars
5597/// that exist (ragged-edge, matching engine FSM). `register` is honoured;
5598/// `'"'` selects the unnamed register.
5599pub(crate) fn delete_block_bridge<H: crate::types::Host>(
5600    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5601    top_row: usize,
5602    bot_row: usize,
5603    left_col: usize,
5604    right_col: usize,
5605    register: char,
5606) {
5607    ed.vim.pending_register = Some(register);
5608    let saved_anchor = ed.vim.block_anchor;
5609    let saved_vcol = ed.vim.block_vcol;
5610    ed.vim.block_anchor = (top_row, left_col);
5611    ed.vim.block_vcol = right_col;
5612    // Compute clamped col before the mutable borrow for buf_set_cursor_rc.
5613    let clamped = right_col.min(buf_line_chars(&ed.buffer, bot_row).saturating_sub(1));
5614    // Place cursor at bot_row / right_col so block_bounds resolves correctly.
5615    buf_set_cursor_rc(&mut ed.buffer, bot_row, clamped);
5616    apply_block_operator(ed, Operator::Delete, 1);
5617    // Restore — block_anchor/vcol are only meaningful in VisualBlock mode;
5618    // after the op we're in Normal so restoring is a no-op for the user but
5619    // keeps state coherent if the caller inspects fields.
5620    ed.vim.block_anchor = saved_anchor;
5621    ed.vim.block_vcol = saved_vcol;
5622}
5623
5624/// Yank a rectangular VisualBlock selection into `register`.
5625pub(crate) fn yank_block_bridge<H: crate::types::Host>(
5626    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5627    top_row: usize,
5628    bot_row: usize,
5629    left_col: usize,
5630    right_col: usize,
5631    register: char,
5632) {
5633    ed.vim.pending_register = Some(register);
5634    let saved_anchor = ed.vim.block_anchor;
5635    let saved_vcol = ed.vim.block_vcol;
5636    ed.vim.block_anchor = (top_row, left_col);
5637    ed.vim.block_vcol = right_col;
5638    let clamped = right_col.min(buf_line_chars(&ed.buffer, bot_row).saturating_sub(1));
5639    buf_set_cursor_rc(&mut ed.buffer, bot_row, clamped);
5640    apply_block_operator(ed, Operator::Yank, 1);
5641    ed.vim.block_anchor = saved_anchor;
5642    ed.vim.block_vcol = saved_vcol;
5643}
5644
5645/// Delete a rectangular VisualBlock selection and enter Insert mode (`c`).
5646/// The deleted text is stashed in `register`. Mode is Insert on return.
5647pub(crate) fn change_block_bridge<H: crate::types::Host>(
5648    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5649    top_row: usize,
5650    bot_row: usize,
5651    left_col: usize,
5652    right_col: usize,
5653    register: char,
5654) {
5655    ed.vim.pending_register = Some(register);
5656    let saved_anchor = ed.vim.block_anchor;
5657    let saved_vcol = ed.vim.block_vcol;
5658    ed.vim.block_anchor = (top_row, left_col);
5659    ed.vim.block_vcol = right_col;
5660    let clamped = right_col.min(buf_line_chars(&ed.buffer, bot_row).saturating_sub(1));
5661    buf_set_cursor_rc(&mut ed.buffer, bot_row, clamped);
5662    apply_block_operator(ed, Operator::Change, 1);
5663    ed.vim.block_anchor = saved_anchor;
5664    ed.vim.block_vcol = saved_vcol;
5665}
5666
5667/// Indent (`count > 0`) or outdent (`count < 0`) rows `top_row..=bot_row`.
5668/// Column bounds are ignored — vim's block indent is always linewise.
5669/// `count == 0` is a no-op.
5670pub(crate) fn indent_block_bridge<H: crate::types::Host>(
5671    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5672    top_row: usize,
5673    bot_row: usize,
5674    count: i32,
5675) {
5676    if count == 0 {
5677        return;
5678    }
5679    ed.push_undo();
5680    let abs = count.unsigned_abs() as usize;
5681    if count > 0 {
5682        indent_rows(ed, top_row, bot_row, abs);
5683    } else {
5684        outdent_rows(ed, top_row, bot_row, abs);
5685    }
5686    ed.vim.mode = Mode::Normal;
5687}
5688
5689/// Auto-indent (v1 dumb shiftwidth) the row span `[start.0, end.0]`. Column
5690/// parts are ignored — auto-indent is always linewise. See
5691/// `auto_indent_rows` for the algorithm and its v1 limitations.
5692pub(crate) fn auto_indent_range_bridge<H: crate::types::Host>(
5693    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5694    start: (usize, usize),
5695    end: (usize, usize),
5696) {
5697    let (top_row, bot_row) = if start.0 <= end.0 {
5698        (start.0, end.0)
5699    } else {
5700        (end.0, start.0)
5701    };
5702    ed.push_undo();
5703    auto_indent_rows(ed, top_row, bot_row);
5704    ed.vim.mode = Mode::Normal;
5705}
5706
5707// ─── Phase 4b pub text-object resolution bridges ───────────────────────────
5708//
5709// These are `pub(crate)` entry points called by the four new pub methods on
5710// `Editor` (`text_object_inner_word`, `text_object_around_word`,
5711// `text_object_inner_big_word`, `text_object_around_big_word`). They delegate
5712// to `word_text_object` — the existing private resolver — without touching any
5713// operator, register, or mode state. Pure functions: only `&Editor` required.
5714
5715/// Resolve the range of `iw` (inner word) at the current cursor position.
5716/// Returns `None` if no word exists at the cursor.
5717pub(crate) fn text_object_inner_word_bridge<H: crate::types::Host>(
5718    ed: &Editor<hjkl_buffer::Buffer, H>,
5719) -> Option<((usize, usize), (usize, usize))> {
5720    word_text_object(ed, true, false)
5721}
5722
5723/// Resolve the range of `aw` (around word) at the current cursor position.
5724/// Includes trailing whitespace (or leading whitespace if no trailing exists).
5725pub(crate) fn text_object_around_word_bridge<H: crate::types::Host>(
5726    ed: &Editor<hjkl_buffer::Buffer, H>,
5727) -> Option<((usize, usize), (usize, usize))> {
5728    word_text_object(ed, false, false)
5729}
5730
5731/// Resolve the range of `iW` (inner WORD) at the current cursor position.
5732/// A WORD is any run of non-whitespace characters (no punctuation splitting).
5733pub(crate) fn text_object_inner_big_word_bridge<H: crate::types::Host>(
5734    ed: &Editor<hjkl_buffer::Buffer, H>,
5735) -> Option<((usize, usize), (usize, usize))> {
5736    word_text_object(ed, true, true)
5737}
5738
5739/// Resolve the range of `aW` (around WORD) at the current cursor position.
5740/// Includes trailing whitespace (or leading whitespace if no trailing exists).
5741pub(crate) fn text_object_around_big_word_bridge<H: crate::types::Host>(
5742    ed: &Editor<hjkl_buffer::Buffer, H>,
5743) -> Option<((usize, usize), (usize, usize))> {
5744    word_text_object(ed, false, true)
5745}
5746
5747// ─── Phase 4c pub text-object resolution bridges (quote + bracket) ──────────
5748//
5749// `pub(crate)` entry points called by the four new pub methods on `Editor`
5750// (`text_object_inner_quote`, `text_object_around_quote`,
5751// `text_object_inner_bracket`, `text_object_around_bracket`). They delegate to
5752// `quote_text_object` / `bracket_text_object` — the existing private resolvers
5753// — without touching any operator, register, or mode state.
5754//
5755// `bracket_text_object` returns `Option<(Pos, Pos, RangeKind)>`; the bridges
5756// strip the `RangeKind` tag so callers see a uniform
5757// `Option<((usize,usize),(usize,usize))>` shape, consistent with 4b.
5758
5759/// Resolve the range of `i<quote>` (inner quote) at the current cursor
5760/// position. `quote` is one of `'"'`, `'\''`, or `` '`' ``. Returns `None`
5761/// when the cursor's line contains fewer than two occurrences of `quote`.
5762pub(crate) fn text_object_inner_quote_bridge<H: crate::types::Host>(
5763    ed: &Editor<hjkl_buffer::Buffer, H>,
5764    quote: char,
5765) -> Option<((usize, usize), (usize, usize))> {
5766    quote_text_object(ed, quote, true)
5767}
5768
5769/// Resolve the range of `a<quote>` (around quote) at the current cursor
5770/// position. Includes surrounding whitespace on one side per vim semantics.
5771pub(crate) fn text_object_around_quote_bridge<H: crate::types::Host>(
5772    ed: &Editor<hjkl_buffer::Buffer, H>,
5773    quote: char,
5774) -> Option<((usize, usize), (usize, usize))> {
5775    quote_text_object(ed, quote, false)
5776}
5777
5778/// Resolve the range of `i<bracket>` (inner bracket pair). `open` must be
5779/// one of `'('`, `'{'`, `'['`, `'<'`; the corresponding close is derived
5780/// internally. Returns `None` when no enclosing pair is found. The returned
5781/// range excludes the bracket characters themselves. Multi-line bracket pairs
5782/// whose content spans more than one line are reported as a charwise range
5783/// covering the first content character through the last content character
5784/// (RangeKind metadata is stripped — callers receive start/end only).
5785pub(crate) fn text_object_inner_bracket_bridge<H: crate::types::Host>(
5786    ed: &Editor<hjkl_buffer::Buffer, H>,
5787    open: char,
5788) -> Option<((usize, usize), (usize, usize))> {
5789    bracket_text_object(ed, open, true, 1).map(|(s, e, _kind)| (s, e))
5790}
5791
5792/// Resolve the range of `a<bracket>` (around bracket pair). Includes the
5793/// bracket characters themselves. `open` must be one of `'('`, `'{'`, `'['`,
5794/// `'<'`.
5795pub(crate) fn text_object_around_bracket_bridge<H: crate::types::Host>(
5796    ed: &Editor<hjkl_buffer::Buffer, H>,
5797    open: char,
5798) -> Option<((usize, usize), (usize, usize))> {
5799    bracket_text_object(ed, open, false, 1).map(|(s, e, _kind)| (s, e))
5800}
5801
5802// ── Sentence bridges (is / as) ─────────────────────────────────────────────
5803
5804/// Resolve the range of `is` (inner sentence) at the cursor. Excludes
5805/// trailing whitespace.
5806pub(crate) fn text_object_inner_sentence_bridge<H: crate::types::Host>(
5807    ed: &Editor<hjkl_buffer::Buffer, H>,
5808) -> Option<((usize, usize), (usize, usize))> {
5809    sentence_text_object(ed, true)
5810}
5811
5812/// Resolve the range of `as` (around sentence) at the cursor. Includes
5813/// trailing whitespace.
5814pub(crate) fn text_object_around_sentence_bridge<H: crate::types::Host>(
5815    ed: &Editor<hjkl_buffer::Buffer, H>,
5816) -> Option<((usize, usize), (usize, usize))> {
5817    sentence_text_object(ed, false)
5818}
5819
5820// ── Paragraph bridges (ip / ap) ────────────────────────────────────────────
5821
5822/// Resolve the range of `ip` (inner paragraph) at the cursor. A paragraph
5823/// is a block of non-blank lines bounded by blank lines or buffer edges.
5824pub(crate) fn text_object_inner_paragraph_bridge<H: crate::types::Host>(
5825    ed: &Editor<hjkl_buffer::Buffer, H>,
5826) -> Option<((usize, usize), (usize, usize))> {
5827    paragraph_text_object(ed, true)
5828}
5829
5830/// Resolve the range of `ap` (around paragraph) at the cursor. Includes one
5831/// trailing blank line when present.
5832pub(crate) fn text_object_around_paragraph_bridge<H: crate::types::Host>(
5833    ed: &Editor<hjkl_buffer::Buffer, H>,
5834) -> Option<((usize, usize), (usize, usize))> {
5835    paragraph_text_object(ed, false)
5836}
5837
5838// ── Tag bridges (it / at) ──────────────────────────────────────────────────
5839
5840/// Resolve the range of `it` (inner tag) at the cursor. Matches XML/HTML-style
5841/// `<tag>...</tag>` pairs; returns the range of inner content between the open
5842/// and close tags.
5843pub(crate) fn text_object_inner_tag_bridge<H: crate::types::Host>(
5844    ed: &Editor<hjkl_buffer::Buffer, H>,
5845) -> Option<((usize, usize), (usize, usize))> {
5846    tag_text_object(ed, true)
5847}
5848
5849/// Resolve the range of `at` (around tag) at the cursor. Includes the open
5850/// and close tag delimiters themselves.
5851pub(crate) fn text_object_around_tag_bridge<H: crate::types::Host>(
5852    ed: &Editor<hjkl_buffer::Buffer, H>,
5853) -> Option<((usize, usize), (usize, usize))> {
5854    tag_text_object(ed, false)
5855}
5856
5857// ─── Rope utility helpers ──────────────────────────────────────────────────
5858
5859/// Return row `r` from a rope as an owned `String`, stripping the
5860/// trailing `\n` that ropey includes on non-final lines.
5861pub(crate) fn rope_line_to_str(rope: &ropey::Rope, r: usize) -> String {
5862    let s = rope.line(r).to_string();
5863    // ropey includes the newline; strip it so callers see bare content.
5864    if s.ends_with('\n') {
5865        s[..s.len() - 1].to_string()
5866    } else {
5867        s
5868    }
5869}
5870
5871/// Join rows `lo..=hi` from a rope into a single `String` separated by
5872/// `\n`. Callers must ensure `lo <= hi < rope.len_lines()`.
5873pub(crate) fn rope_row_range_str(rope: &ropey::Rope, lo: usize, hi: usize) -> String {
5874    let n = rope.len_lines();
5875    let lo = lo.min(n.saturating_sub(1));
5876    let hi = hi.min(n.saturating_sub(1));
5877    if lo > hi {
5878        return String::new();
5879    }
5880    // Use byte-slice to grab the full range in one rope walk.
5881    let start_byte = rope.line_to_byte(lo);
5882    // End byte: start of line hi+1, minus the newline separator, or
5883    // len_bytes() when hi is the last line.
5884    let end_byte = if hi + 1 < n {
5885        // line_to_byte(hi+1) points at the \n-terminated start of
5886        // the next line; step back one byte to drop that trailing \n.
5887        rope.line_to_byte(hi + 1).saturating_sub(1)
5888    } else {
5889        rope.len_bytes()
5890    };
5891    rope.byte_slice(start_byte..end_byte).to_string()
5892}
5893
5894/// Snapshot all rows from a rope as `Vec<String>` (no trailing `\n`).
5895/// Use only when the caller truly needs mutable per-row access; prefer
5896/// rope iterators otherwise.
5897pub(crate) fn rope_to_lines_vec(rope: &ropey::Rope) -> Vec<String> {
5898    let n = rope.len_lines();
5899    (0..n).map(|r| rope_line_to_str(rope, r)).collect()
5900}
5901
5902/// Pure greedy word-wrap of a slice of lines to `width` chars.
5903/// Returns `(original_slice, wrapped_lines)`.
5904/// Blank lines are preserved as paragraph separators.
5905fn greedy_wrap(original: &[String], width: usize) -> Vec<String> {
5906    let mut wrapped: Vec<String> = Vec::new();
5907    let mut paragraph: Vec<String> = Vec::new();
5908    let flush = |para: &mut Vec<String>, out: &mut Vec<String>, width: usize| {
5909        if para.is_empty() {
5910            return;
5911        }
5912        let words = para.join(" ");
5913        let mut current = String::new();
5914        for word in words.split_whitespace() {
5915            let extra = if current.is_empty() {
5916                word.chars().count()
5917            } else {
5918                current.chars().count() + 1 + word.chars().count()
5919            };
5920            if extra > width && !current.is_empty() {
5921                out.push(std::mem::take(&mut current));
5922                current.push_str(word);
5923            } else if current.is_empty() {
5924                current.push_str(word);
5925            } else {
5926                current.push(' ');
5927                current.push_str(word);
5928            }
5929        }
5930        if !current.is_empty() {
5931            out.push(current);
5932        }
5933        para.clear();
5934    };
5935    for line in original {
5936        if line.trim().is_empty() {
5937            flush(&mut paragraph, &mut wrapped, width);
5938            wrapped.push(String::new());
5939        } else {
5940            paragraph.push(line.clone());
5941        }
5942    }
5943    flush(&mut paragraph, &mut wrapped, width);
5944    wrapped
5945}
5946
5947/// Greedy word-wrap the rows in `[top, bot]` to `settings.textwidth`.
5948/// Splits on blank-line boundaries so paragraph structure is
5949/// preserved. Each paragraph's words are joined with single spaces
5950/// before re-wrapping. Cursor lands at `(top, 0)` after the call
5951/// (via `ed.restore`).
5952fn reflow_rows<H: crate::types::Host>(
5953    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5954    top: usize,
5955    bot: usize,
5956) {
5957    let width = ed.settings().textwidth.max(1);
5958    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
5959    let bot = bot.min(lines.len().saturating_sub(1));
5960    if top > bot {
5961        return;
5962    }
5963    let original = lines[top..=bot].to_vec();
5964    let wrapped = greedy_wrap(&original, width);
5965
5966    // vim leaves the cursor on the last NON-BLANK line of the reflowed range
5967    // (a trailing blank from `ap` etc. is not counted).
5968    let last_offset = wrapped
5969        .iter()
5970        .rposition(|l| !l.trim().is_empty())
5971        .unwrap_or(0);
5972    let last_row = top + last_offset;
5973
5974    // Splice back. push_undo above means `u` reverses.
5975    let after: Vec<String> = lines.split_off(bot + 1);
5976    lines.truncate(top);
5977    lines.extend(wrapped);
5978    lines.extend(after);
5979    ed.restore(lines, (last_row, 0));
5980    move_first_non_whitespace(ed);
5981    ed.mark_content_dirty();
5982}
5983
5984/// Same reflow as `reflow_rows` but also returns the pre-reflow slice
5985/// and the wrapped lines so the caller can compute a character-preserving
5986/// cursor position via [`reflow_keep_cursor`].
5987fn reflow_rows_keep_cursor<H: crate::types::Host>(
5988    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5989    top: usize,
5990    bot: usize,
5991) -> (Vec<String>, Vec<String>) {
5992    let width = ed.settings().textwidth.max(1);
5993    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
5994    let bot = bot.min(lines.len().saturating_sub(1));
5995    if top > bot {
5996        return (Vec::new(), Vec::new());
5997    }
5998    let original = lines[top..=bot].to_vec();
5999    let wrapped = greedy_wrap(&original, width);
6000
6001    let after: Vec<String> = lines.split_off(bot + 1);
6002    lines.truncate(top);
6003    lines.extend(wrapped.clone());
6004    lines.extend(after);
6005    ed.restore(lines, (top, 0));
6006    ed.mark_content_dirty();
6007    (original, wrapped)
6008}
6009
6010/// Compute the new `(row, col)` that preserves the character the cursor
6011/// was on after `reflow_rows` has been applied to `[top, bot]`.
6012///
6013/// Algorithm (mirrors nvim's `gw` behaviour):
6014/// 1. Count the char-index of `(cursor_row, cursor_col)` relative to the
6015///    start of line `top` in `before_lines` (the pre-reflow snapshot).
6016/// 2. Walk the `after_lines` (the wrapped output) to find the row/col
6017///    that has the same char index.
6018///
6019/// If the cursor was past the end of the reflowed content (e.g. beyond
6020/// the last char), we clamp to the last char of the last reflowed line.
6021fn reflow_keep_cursor(
6022    top: usize,
6023    cursor_row: usize,
6024    cursor_col: usize,
6025    before_lines: &[String],
6026    after_lines: &[String],
6027) -> (usize, usize) {
6028    // Char offset of cursor within the before_lines range.
6029    // Each line contributes its chars; lines are separated by a single
6030    // space in the collapsed paragraph — but since reflow joins everything
6031    // and re-wraps with spaces, counting by chars-per-line (plus the
6032    // conceptual space separator between lines) mirrors the join.
6033    //
6034    // The simpler approach (which nvim appears to use): the cursor offset
6035    // within the range is the sum of chars in lines before cursor_row
6036    // (each + 1 for the space/newline separator) plus cursor_col, then
6037    // find that position in the wrapped text.
6038    //
6039    // Actually, since reflow collapses whitespace (split_whitespace),
6040    // the simplest approach is to track the cursor's char in the ORIGINAL
6041    // concatenated text and find it in the reflowed text.
6042
6043    // Build the original range text as it appears when joined for wrapping:
6044    // same as what reflow does internally — join with spaces.
6045    // But we want raw character index, so we accumulate char counts per line
6046    // (without the trailing newline).
6047    let relative_row = cursor_row.saturating_sub(top);
6048    let mut char_offset: usize = 0;
6049    for (i, line) in before_lines.iter().enumerate() {
6050        if i == relative_row {
6051            // Add clamped col within this line.
6052            let line_len = line.chars().count();
6053            char_offset += cursor_col.min(line_len);
6054            break;
6055        }
6056        // Each line contributes its chars plus a newline (or space boundary).
6057        char_offset += line.chars().count() + 1;
6058    }
6059
6060    // Now find char_offset in after_lines.
6061    let mut remaining = char_offset;
6062    for (i, line) in after_lines.iter().enumerate() {
6063        let len = line.chars().count();
6064        if remaining <= len {
6065            // The col is clamped to line_len - 1 in Normal mode.
6066            let col = remaining.min(if len == 0 { 0 } else { len.saturating_sub(1) });
6067            return (top + i, col);
6068        }
6069        // Not on this line; subtract line len + 1 (newline separator).
6070        remaining = remaining.saturating_sub(len + 1);
6071    }
6072
6073    // Cursor was beyond the end of the reflowed content — clamp to last line.
6074    let last = after_lines.len().saturating_sub(1);
6075    let last_len = after_lines
6076        .get(last)
6077        .map(|l| l.chars().count())
6078        .unwrap_or(0);
6079    let col = if last_len == 0 { 0 } else { last_len - 1 };
6080    (top + last, col)
6081}
6082
6083/// Transform the range `[top, bot]` (vim `RangeKind`) in place with
6084/// the given case operator. Cursor lands on `top` afterward — vim
6085/// convention for `gU{motion}` / `gu{motion}` / `g~{motion}`.
6086/// Preserves the textarea yank buffer (vim's case operators don't
6087/// touch registers).
6088fn apply_case_op_to_selection<H: crate::types::Host>(
6089    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6090    op: Operator,
6091    top: (usize, usize),
6092    bot: (usize, usize),
6093    kind: RangeKind,
6094) {
6095    use hjkl_buffer::Edit;
6096    ed.push_undo();
6097    let saved_yank = ed.yank().to_string();
6098    let saved_yank_linewise = ed.vim.yank_linewise;
6099    let selection = cut_vim_range(ed, top, bot, kind);
6100    let transformed = match op {
6101        Operator::Uppercase => selection.to_uppercase(),
6102        Operator::Lowercase => selection.to_lowercase(),
6103        Operator::ToggleCase => toggle_case_str(&selection),
6104        Operator::Rot13 => rot13_str(&selection),
6105        _ => unreachable!(),
6106    };
6107    if !transformed.is_empty() {
6108        let cursor = buf_cursor_pos(&ed.buffer);
6109        ed.mutate_edit(Edit::InsertStr {
6110            at: cursor,
6111            text: transformed,
6112        });
6113    }
6114    buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
6115    ed.push_buffer_cursor_to_textarea();
6116    ed.set_yank(saved_yank);
6117    ed.vim.yank_linewise = saved_yank_linewise;
6118    ed.vim.mode = Mode::Normal;
6119}
6120
6121/// Prepend `count * shiftwidth` spaces to each row in `[top, bot]`.
6122/// Rows that are empty are skipped (vim leaves blank lines alone when
6123/// indenting). `shiftwidth` is read from `editor.settings()` so
6124/// `:set shiftwidth=N` takes effect on the next operation.
6125fn indent_rows<H: crate::types::Host>(
6126    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6127    top: usize,
6128    bot: usize,
6129    count: usize,
6130) {
6131    ed.sync_buffer_content_from_textarea();
6132    let width = ed.settings().shiftwidth * count.max(1);
6133    let pad: String = " ".repeat(width);
6134    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
6135    let bot = bot.min(lines.len().saturating_sub(1));
6136    for line in lines.iter_mut().take(bot + 1).skip(top) {
6137        if !line.is_empty() {
6138            line.insert_str(0, &pad);
6139        }
6140    }
6141    // Restore cursor to first non-blank of the top row so the next
6142    // vertical motion aims sensibly — matches vim's `>>` convention.
6143    ed.restore(lines, (top, 0));
6144    move_first_non_whitespace(ed);
6145}
6146
6147/// Remove up to `count * shiftwidth` leading spaces (or tabs) from
6148/// each row in `[top, bot]`. Rows with less leading whitespace have
6149/// all their indent stripped, not clipped to zero length.
6150fn outdent_rows<H: crate::types::Host>(
6151    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6152    top: usize,
6153    bot: usize,
6154    count: usize,
6155) {
6156    ed.sync_buffer_content_from_textarea();
6157    let width = ed.settings().shiftwidth * count.max(1);
6158    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
6159    let bot = bot.min(lines.len().saturating_sub(1));
6160    for line in lines.iter_mut().take(bot + 1).skip(top) {
6161        let strip: usize = line
6162            .chars()
6163            .take(width)
6164            .take_while(|c| *c == ' ' || *c == '\t')
6165            .count();
6166        if strip > 0 {
6167            let byte_len: usize = line.chars().take(strip).map(|c| c.len_utf8()).sum();
6168            line.drain(..byte_len);
6169        }
6170    }
6171    ed.restore(lines, (top, 0));
6172    move_first_non_whitespace(ed);
6173}
6174
6175/// Count the number of open/close bracket pairs on a single line for the
6176/// auto-indent depth scanner. Only bare bracket scanning — does NOT handle
6177/// string literals or comments (v1 limitation, documented on
6178/// `auto_indent_range_bridge`).
6179/// Net bracket count `(open - close)` for a single line, skipping
6180/// brackets inside `//` line comments, `"..."` string literals, and
6181/// `'X'` char literals.
6182///
6183/// String / char escapes (`\"`, `\'`, `\\`) are honored so the closing
6184/// quote isn't missed when the literal contains a backslash.
6185///
6186/// Limitations:
6187/// - Block comments `/* ... */` are NOT tracked across lines (a single
6188///   line `/* foo { bar } */` is correctly skipped only because the
6189///   `/*` and `*/` are on the same line and we'd see `{` after `/*`).
6190///   For v1 we leave this since block comments mid-code are rare.
6191/// - Raw string literals `r"..."` / `r#"..."#` are NOT special-cased.
6192/// - Lifetime annotations like `'a` look like an unterminated char
6193///   literal — handled by the heuristic that a char literal MUST close
6194///   within the line; if the closing `'` isn't found, treat the `'` as
6195///   a normal character (lifetime).
6196///
6197/// Pre-fix the scan was naive — `//! ... }` on a doc comment
6198/// decremented depth, cascading wrong indentation through the rest of
6199/// the file. This caused ~19% of lines to mis-indent on a real Rust
6200/// source diagnostic.
6201fn bracket_net(line: &str) -> i32 {
6202    let mut net: i32 = 0;
6203    let mut chars = line.chars().peekable();
6204    while let Some(ch) = chars.next() {
6205        match ch {
6206            // `//` → rest of line is a comment, stop.
6207            '/' if chars.peek() == Some(&'/') => return net,
6208            '"' => {
6209                // String literal — consume until unescaped closing `"`.
6210                while let Some(c) = chars.next() {
6211                    match c {
6212                        '\\' => {
6213                            chars.next();
6214                        } // skip escape byte
6215                        '"' => break,
6216                        _ => {}
6217                    }
6218                }
6219            }
6220            '\'' => {
6221                // Char literal OR lifetime. A char literal closes within
6222                // a few chars (one or two for escapes). A lifetime is
6223                // `'ident` with no closing quote.
6224                //
6225                // Strategy: peek ahead for a closing `'`. If found
6226                // within ~4 chars, consume as char literal. Otherwise
6227                // treat the `'` as the start of a lifetime — leave the
6228                // remaining chars to be scanned normally.
6229                let saved: Vec<char> = chars.clone().take(5).collect();
6230                let close_idx = if saved.first() == Some(&'\\') {
6231                    saved.iter().skip(2).position(|&c| c == '\'').map(|p| p + 2)
6232                } else {
6233                    saved.iter().skip(1).position(|&c| c == '\'').map(|p| p + 1)
6234                };
6235                if let Some(idx) = close_idx {
6236                    for _ in 0..=idx {
6237                        chars.next();
6238                    }
6239                }
6240                // If no close found, leave chars alone — lifetime path.
6241            }
6242            '{' | '(' | '[' => net += 1,
6243            '}' | ')' | ']' => net -= 1,
6244            _ => {}
6245        }
6246    }
6247    net
6248}
6249
6250/// Reindent rows `[top, bot]` using shiftwidth-based bracket-depth counting.
6251///
6252/// The indent for each line is computed as follows:
6253/// 1. Scan all rows from 0 up to the target row, accumulating a bracket depth
6254///    (`depth`) from net open − close brackets per line. The scan starts at row
6255///    0 to give correct depth for code that appears mid-buffer.
6256/// 2. For the target line, peek at its first non-whitespace character:
6257///    if it is a close bracket (`}`, `)`, `]`) then `effective_depth =
6258///    depth.saturating_sub(1)`; otherwise `effective_depth = depth`.
6259/// 3. Strip the line's existing leading whitespace and prepend
6260///    `effective_depth × indent_unit` where `indent_unit` is `"\t"` when
6261///    `expandtab == false` or `" " × shiftwidth` when `expandtab == true`.
6262/// 4. Empty / whitespace-only lines are left empty (no trailing whitespace).
6263/// 5. After computing the new line, advance `depth` by the line's bracket
6264///    net count (open − close), where the leading close-bracket already
6265///    contributed `−1` to the net of its own line.
6266///
6267/// **v1 limitation**: the bracket scan is naive — it does not skip brackets
6268/// inside string literals (`"{"`, `'['`) or comments (`// {`). Code with
6269/// such patterns will produce incorrect indent depths. Tree-sitter / LSP
6270/// indentation is deferred to a follow-up.
6271fn auto_indent_rows<H: crate::types::Host>(
6272    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6273    top: usize,
6274    bot: usize,
6275) {
6276    ed.sync_buffer_content_from_textarea();
6277    let shiftwidth = ed.settings().shiftwidth;
6278    let expandtab = ed.settings().expandtab;
6279    let indent_unit: String = if expandtab {
6280        " ".repeat(shiftwidth)
6281    } else {
6282        "\t".to_string()
6283    };
6284
6285    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
6286    let bot = bot.min(lines.len().saturating_sub(1));
6287
6288    // Accumulate bracket depth from row 0 up to `top - 1` so we start with
6289    // the correct depth for the first line of the target range.
6290    let mut depth: i32 = 0;
6291    for line in lines.iter().take(top) {
6292        depth += bracket_net(line);
6293        if depth < 0 {
6294            depth = 0;
6295        }
6296    }
6297
6298    for line in lines.iter_mut().take(bot + 1).skip(top) {
6299        let trimmed_owned = line.trim_start().to_owned();
6300        // Empty / whitespace-only lines stay empty.
6301        if trimmed_owned.is_empty() {
6302            *line = String::new();
6303            // depth contribution from an empty line is zero; no bracket scan needed.
6304            continue;
6305        }
6306
6307        // Detect leading close-bracket for effective depth.
6308        let starts_with_close = trimmed_owned
6309            .chars()
6310            .next()
6311            .is_some_and(|c| matches!(c, '}' | ')' | ']'));
6312        // Chain continuation: a line starting with `.` (e.g. `.foo()`)
6313        // hangs off the previous expression and gets one extra indent
6314        // level, matching cargo fmt / clang-format conventions for
6315        // method chains like:
6316        //   let x = foo()
6317        //       .bar()
6318        //       .baz();
6319        // Range expressions (`..`) and try-chains (`?.`) are out of
6320        // scope for v1 — single leading `.` is the common case.
6321        let starts_with_dot = trimmed_owned.starts_with('.')
6322            && !trimmed_owned.starts_with("..")
6323            && !trimmed_owned.starts_with(".;");
6324        let effective_depth = if starts_with_close {
6325            depth.saturating_sub(1)
6326        } else if starts_with_dot {
6327            depth.saturating_add(1)
6328        } else {
6329            depth
6330        } as usize;
6331
6332        // Build new line: indent × depth + stripped content.
6333        let new_line = format!("{}{}", indent_unit.repeat(effective_depth), trimmed_owned);
6334
6335        // Advance depth by this line's net bracket count (scan trimmed content).
6336        depth += bracket_net(&trimmed_owned);
6337        if depth < 0 {
6338            depth = 0;
6339        }
6340
6341        *line = new_line;
6342    }
6343
6344    // Restore cursor to the first non-blank of `top` (vim parity for `==`).
6345    ed.restore(lines, (top, 0));
6346    move_first_non_whitespace(ed);
6347    // Record the touched row range so the host can display a visual flash.
6348    ed.last_indent_range = Some((top, bot));
6349}
6350
6351fn toggle_case_str(s: &str) -> String {
6352    s.chars()
6353        .map(|c| {
6354            if c.is_lowercase() {
6355                c.to_uppercase().next().unwrap_or(c)
6356            } else if c.is_uppercase() {
6357                c.to_lowercase().next().unwrap_or(c)
6358            } else {
6359                c
6360            }
6361        })
6362        .collect()
6363}
6364
6365fn order(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) {
6366    if a <= b { (a, b) } else { (b, a) }
6367}
6368
6369/// Clamp the buffer cursor to normal-mode valid position: col may not
6370/// exceed `line.chars().count().saturating_sub(1)` (or 0 on an empty
6371/// line). Vim applies this clamp on every return to Normal mode after an
6372/// operator or Esc-from-insert.
6373fn clamp_cursor_to_normal_mode<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
6374    let (row, col) = ed.cursor();
6375    let line_chars = buf_line_chars(&ed.buffer, row);
6376    let max_col = line_chars.saturating_sub(1);
6377    if col > max_col {
6378        buf_set_cursor_rc(&mut ed.buffer, row, max_col);
6379        ed.push_buffer_cursor_to_textarea();
6380    }
6381}
6382
6383// ─── dd/cc/yy ──────────────────────────────────────────────────────────────
6384
6385/// Expand a linewise `[start, end]` row range so it fully covers every CLOSED
6386/// fold it overlaps — vim's rule that a linewise operator on a closed fold acts
6387/// on the whole fold. Loops until stable so nested closed folds are absorbed.
6388fn expand_linewise_over_closed_folds(
6389    buf: &hjkl_buffer::Buffer,
6390    mut start: usize,
6391    mut end: usize,
6392) -> (usize, usize) {
6393    let folds = buf.folds();
6394    if folds.is_empty() {
6395        return (start, end);
6396    }
6397    loop {
6398        let mut changed = false;
6399        for f in &folds {
6400            if !f.closed {
6401                continue;
6402            }
6403            // Does this closed fold overlap the current range?
6404            if f.start_row <= end && f.end_row >= start {
6405                if f.start_row < start {
6406                    start = f.start_row;
6407                    changed = true;
6408                }
6409                if f.end_row > end {
6410                    end = f.end_row;
6411                    changed = true;
6412                }
6413            }
6414        }
6415        if !changed {
6416            break;
6417        }
6418    }
6419    (start, end)
6420}
6421
6422fn execute_line_op<H: crate::types::Host>(
6423    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6424    op: Operator,
6425    count: usize,
6426) {
6427    let (row, col) = ed.cursor();
6428    let total = buf_row_count(&ed.buffer);
6429    // Vim: `[count]op` for a linewise operator implies a `count_` motion that
6430    // moves `count - 1` lines down. On the last line that motion can't move at
6431    // all, so the whole operator aborts (E16) — `2dd`/`2yy`/`5>>`/`5<<` on the
6432    // final line are no-ops, not "operate on the one remaining line". When the
6433    // cursor is above the last line the motion clamps to the buffer end instead.
6434    //
6435    // A trailing newline is stored as a phantom empty final row, so the last
6436    // *content* line is one above it; use that as the boundary.
6437    let last_content_row = if total >= 2
6438        && buf_line(&ed.buffer, total - 1)
6439            .map(|s| s.is_empty())
6440            .unwrap_or(false)
6441    {
6442        total - 2
6443    } else {
6444        total.saturating_sub(1)
6445    };
6446    if count >= 2 && row >= last_content_row {
6447        return;
6448    }
6449    let end_row = (row + count.saturating_sub(1)).min(total.saturating_sub(1));
6450
6451    // Vim: a linewise operator (`dd`/`yy`/`cc`/`>>`/…) with the cursor on a
6452    // CLOSED fold operates on the ENTIRE fold, not just the cursor line. Expand
6453    // the `[row, end_row]` range to cover any closed fold it touches (repeats
6454    // until stable so nested folds are absorbed too).
6455    let (row, end_row) = expand_linewise_over_closed_folds(&ed.buffer, row, end_row);
6456
6457    match op {
6458        Operator::Yank => {
6459            // yy must not move the cursor.
6460            let text = read_vim_range(ed, (row, col), (end_row, 0), RangeKind::Linewise);
6461            if !text.is_empty() {
6462                ed.record_yank_to_host(text.clone());
6463                ed.record_yank(text, true);
6464            }
6465            // Vim `:h '[` / `:h ']`: yy/Nyy — linewise yank; `[` =
6466            // (top_row, 0), `]` = (bot_row, last_col).
6467            let last_col = buf_line_chars(&ed.buffer, end_row).saturating_sub(1);
6468            ed.set_mark('[', (row, 0));
6469            ed.set_mark(']', (end_row, last_col));
6470            buf_set_cursor_rc(&mut ed.buffer, row, col);
6471            ed.push_buffer_cursor_to_textarea();
6472            ed.vim.mode = Mode::Normal;
6473        }
6474        Operator::Delete => {
6475            ed.push_undo();
6476            let deleted_through_last = end_row + 1 >= total;
6477            cut_vim_range(ed, (row, col), (end_row, 0), RangeKind::Linewise);
6478            // Vim's `dd` / `Ndd` leaves the cursor on the *first
6479            // non-blank* of the line that now occupies `row` — or, if
6480            // the deletion consumed the last line, the line above it.
6481            let total_after = buf_row_count(&ed.buffer);
6482            let raw_target = if deleted_through_last {
6483                row.saturating_sub(1).min(total_after.saturating_sub(1))
6484            } else {
6485                row.min(total_after.saturating_sub(1))
6486            };
6487            // Clamp off the trailing phantom empty row that arises from a
6488            // buffer with a trailing newline (stored as ["...", ""]). If
6489            // the target row is the trailing empty row and there is a real
6490            // content row above it, use that instead — matching vim's view
6491            // that the trailing `\n` is a terminator, not a separator.
6492            let target_row = if raw_target > 0
6493                && raw_target + 1 == total_after
6494                && buf_line(&ed.buffer, raw_target)
6495                    .map(|s| s.is_empty())
6496                    .unwrap_or(false)
6497            {
6498                raw_target - 1
6499            } else {
6500                raw_target
6501            };
6502            buf_set_cursor_rc(&mut ed.buffer, target_row, 0);
6503            ed.push_buffer_cursor_to_textarea();
6504            move_first_non_whitespace(ed);
6505            ed.sticky_col = Some(ed.cursor().1);
6506            ed.vim.mode = Mode::Normal;
6507            // Vim `:h '[` / `:h ']`: dd/Ndd — both marks park at the
6508            // post-delete cursor position (the join point).
6509            let pos = ed.cursor();
6510            ed.set_mark('[', pos);
6511            ed.set_mark(']', pos);
6512        }
6513        Operator::Change => {
6514            // `cc` / `3cc`: delegate to the shared linewise-change helper
6515            // which preserves the first line's indent, leaves one row open,
6516            // and enters insert mode.
6517            change_linewise_rows(ed, row, end_row);
6518        }
6519        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {
6520            // `gUU` / `guu` / `g~~` / `g??` — linewise case/rot13 transform over
6521            // [row, end_row]. Preserve cursor on `row` (first non-blank
6522            // lines up with vim's behaviour).
6523            apply_case_op_to_selection(ed, op, (row, col), (end_row, 0), RangeKind::Linewise);
6524            // After case-op on a linewise range vim puts the cursor on
6525            // the first non-blank of the starting line.
6526            move_first_non_whitespace(ed);
6527        }
6528        Operator::Indent | Operator::Outdent => {
6529            // `>>` / `N>>` / `<<` / `N<<` — linewise indent / outdent.
6530            ed.push_undo();
6531            if op == Operator::Indent {
6532                indent_rows(ed, row, end_row, 1);
6533            } else {
6534                outdent_rows(ed, row, end_row, 1);
6535            }
6536            ed.sticky_col = Some(ed.cursor().1);
6537            ed.vim.mode = Mode::Normal;
6538        }
6539        // No doubled form — `zfzf` is two consecutive `zf` chords.
6540        Operator::Fold => unreachable!("Fold has no line-op double"),
6541        Operator::Reflow => {
6542            // `gqq` / `Ngqq` — reflow `count` rows starting at the cursor.
6543            ed.push_undo();
6544            reflow_rows(ed, row, end_row);
6545            move_first_non_whitespace(ed);
6546            ed.sticky_col = Some(ed.cursor().1);
6547            ed.vim.mode = Mode::Normal;
6548        }
6549        Operator::ReflowKeepCursor => {
6550            // `gww` / `Ngww` — reflow `count` rows starting at the cursor,
6551            // but leave the cursor at the character it was on before reflow.
6552            let saved = ed.cursor();
6553            ed.push_undo();
6554            let (before, after) = reflow_rows_keep_cursor(ed, row, end_row);
6555            let (new_row, new_col) = reflow_keep_cursor(row, saved.0, saved.1, &before, &after);
6556            buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
6557            ed.push_buffer_cursor_to_textarea();
6558            ed.sticky_col = Some(new_col);
6559            ed.vim.mode = Mode::Normal;
6560        }
6561        Operator::AutoIndent => {
6562            // `==` / `N==` — auto-indent `count` rows starting at cursor.
6563            ed.push_undo();
6564            auto_indent_rows(ed, row, end_row);
6565            ed.sticky_col = Some(ed.cursor().1);
6566            ed.vim.mode = Mode::Normal;
6567        }
6568        Operator::Filter => {
6569            // Filter is dispatched through Editor::filter_range, not here.
6570        }
6571        Operator::Comment => {
6572            // Comment is dispatched through Editor::toggle_comment_range, not here.
6573            // The doubled `gcc` path calls toggle_comment_range directly in
6574            // apply_after_g, then records last_change. execute_line_op should
6575            // not be reached for Comment — no-op if it is.
6576        }
6577    }
6578}
6579
6580// ─── Visual mode operators ─────────────────────────────────────────────────
6581
6582pub(crate) fn apply_visual_operator<H: crate::types::Host>(
6583    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6584    op: Operator,
6585    count: usize,
6586) {
6587    // `count` is the number of indent levels for `>` / `<` (vim `2>` = two
6588    // shiftwidths); other visual operators ignore it.
6589    let levels = count.max(1);
6590    match ed.vim.mode {
6591        Mode::VisualLine => {
6592            let cursor_row = buf_cursor_pos(&ed.buffer).row;
6593            let top = cursor_row.min(ed.vim.visual_line_anchor);
6594            let bot = cursor_row.max(ed.vim.visual_line_anchor);
6595            ed.vim.yank_linewise = true;
6596            match op {
6597                Operator::Yank => {
6598                    let text = read_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
6599                    if !text.is_empty() {
6600                        ed.record_yank_to_host(text.clone());
6601                        ed.record_yank(text, true);
6602                    }
6603                    buf_set_cursor_rc(&mut ed.buffer, top, 0);
6604                    ed.push_buffer_cursor_to_textarea();
6605                    ed.vim.mode = Mode::Normal;
6606                }
6607                Operator::Delete => {
6608                    ed.push_undo();
6609                    cut_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
6610                    ed.vim.mode = Mode::Normal;
6611                }
6612                Operator::Change => {
6613                    // Vim `Vc` / `Vjc`: same linewise-change semantics as
6614                    // `cc` — preserve first line's indent, enter insert.
6615                    change_linewise_rows(ed, top, bot);
6616                }
6617                Operator::Uppercase
6618                | Operator::Lowercase
6619                | Operator::ToggleCase
6620                | Operator::Rot13 => {
6621                    let bot = buf_cursor_pos(&ed.buffer)
6622                        .row
6623                        .max(ed.vim.visual_line_anchor);
6624                    apply_case_op_to_selection(ed, op, (top, 0), (bot, 0), RangeKind::Linewise);
6625                    move_first_non_whitespace(ed);
6626                }
6627                Operator::Indent | Operator::Outdent => {
6628                    ed.push_undo();
6629                    let (cursor_row, _) = ed.cursor();
6630                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
6631                    if op == Operator::Indent {
6632                        indent_rows(ed, top, bot, levels);
6633                    } else {
6634                        outdent_rows(ed, top, bot, levels);
6635                    }
6636                    ed.vim.mode = Mode::Normal;
6637                }
6638                Operator::Reflow => {
6639                    ed.push_undo();
6640                    let (cursor_row, _) = ed.cursor();
6641                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
6642                    reflow_rows(ed, top, bot);
6643                    ed.vim.mode = Mode::Normal;
6644                }
6645                Operator::ReflowKeepCursor => {
6646                    let saved = ed.cursor();
6647                    ed.push_undo();
6648                    let (cursor_row, _) = ed.cursor();
6649                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
6650                    let (before, after) = reflow_rows_keep_cursor(ed, top, bot);
6651                    let (new_row, new_col) =
6652                        reflow_keep_cursor(top, saved.0, saved.1, &before, &after);
6653                    buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
6654                    ed.push_buffer_cursor_to_textarea();
6655                    ed.vim.mode = Mode::Normal;
6656                }
6657                Operator::AutoIndent => {
6658                    ed.push_undo();
6659                    let (cursor_row, _) = ed.cursor();
6660                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
6661                    auto_indent_rows(ed, top, bot);
6662                    ed.vim.mode = Mode::Normal;
6663                }
6664                // Filter is dispatched through Editor::filter_range, not here.
6665                Operator::Filter => {}
6666                // Comment is dispatched through the app layer (engine_actions.rs), not here.
6667                Operator::Comment => {}
6668                // Visual `zf` is handled inline in `handle_after_z`,
6669                // never routed through this dispatcher.
6670                Operator::Fold => unreachable!("Visual zf takes its own path"),
6671            }
6672        }
6673        Mode::Visual => {
6674            ed.vim.yank_linewise = false;
6675            let anchor = ed.vim.visual_anchor;
6676            let cursor = ed.cursor();
6677            let (top, bot) = order(anchor, cursor);
6678            match op {
6679                Operator::Yank => {
6680                    let text = read_vim_range(ed, top, bot, RangeKind::Inclusive);
6681                    if !text.is_empty() {
6682                        ed.record_yank_to_host(text.clone());
6683                        ed.record_yank(text, false);
6684                    }
6685                    buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
6686                    ed.push_buffer_cursor_to_textarea();
6687                    ed.vim.mode = Mode::Normal;
6688                }
6689                Operator::Delete => {
6690                    ed.push_undo();
6691                    cut_vim_range(ed, top, bot, RangeKind::Inclusive);
6692                    ed.vim.mode = Mode::Normal;
6693                }
6694                Operator::Change => {
6695                    ed.push_undo();
6696                    cut_vim_range(ed, top, bot, RangeKind::Inclusive);
6697                    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
6698                }
6699                Operator::Uppercase
6700                | Operator::Lowercase
6701                | Operator::ToggleCase
6702                | Operator::Rot13 => {
6703                    // Anchor stays where the visual selection started.
6704                    let anchor = ed.vim.visual_anchor;
6705                    let cursor = ed.cursor();
6706                    let (top, bot) = order(anchor, cursor);
6707                    apply_case_op_to_selection(ed, op, top, bot, RangeKind::Inclusive);
6708                }
6709                Operator::Indent | Operator::Outdent => {
6710                    ed.push_undo();
6711                    let anchor = ed.vim.visual_anchor;
6712                    let cursor = ed.cursor();
6713                    let (top, bot) = order(anchor, cursor);
6714                    if op == Operator::Indent {
6715                        indent_rows(ed, top.0, bot.0, levels);
6716                    } else {
6717                        outdent_rows(ed, top.0, bot.0, levels);
6718                    }
6719                    ed.vim.mode = Mode::Normal;
6720                }
6721                Operator::Reflow => {
6722                    ed.push_undo();
6723                    let anchor = ed.vim.visual_anchor;
6724                    let cursor = ed.cursor();
6725                    let (top, bot) = order(anchor, cursor);
6726                    reflow_rows(ed, top.0, bot.0);
6727                    ed.vim.mode = Mode::Normal;
6728                }
6729                Operator::ReflowKeepCursor => {
6730                    let saved = ed.cursor();
6731                    ed.push_undo();
6732                    let anchor = ed.vim.visual_anchor;
6733                    let cursor = ed.cursor();
6734                    let (top, bot) = order(anchor, cursor);
6735                    let (before, after) = reflow_rows_keep_cursor(ed, top.0, bot.0);
6736                    let (new_row, new_col) =
6737                        reflow_keep_cursor(top.0, saved.0, saved.1, &before, &after);
6738                    buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
6739                    ed.push_buffer_cursor_to_textarea();
6740                    ed.vim.mode = Mode::Normal;
6741                }
6742                Operator::AutoIndent => {
6743                    ed.push_undo();
6744                    let anchor = ed.vim.visual_anchor;
6745                    let cursor = ed.cursor();
6746                    let (top, bot) = order(anchor, cursor);
6747                    auto_indent_rows(ed, top.0, bot.0);
6748                    ed.vim.mode = Mode::Normal;
6749                }
6750                // Filter is dispatched through Editor::filter_range, not here.
6751                Operator::Filter => {}
6752                // Comment is dispatched through the app layer (engine_actions.rs), not here.
6753                Operator::Comment => {}
6754                Operator::Fold => unreachable!("Visual zf takes its own path"),
6755            }
6756        }
6757        Mode::VisualBlock => apply_block_operator(ed, op, levels),
6758        _ => {}
6759    }
6760}
6761
6762/// Compute `(top_row, bot_row, left_col, right_col)` for the current
6763/// VisualBlock selection. Columns are inclusive on both ends. Uses the
6764/// tracked virtual column (updated by h/l, preserved across j/k) so
6765/// ragged / empty rows don't collapse the block's width.
6766fn block_bounds<H: crate::types::Host>(
6767    ed: &Editor<hjkl_buffer::Buffer, H>,
6768) -> (usize, usize, usize, usize) {
6769    let (ar, ac) = ed.vim.block_anchor;
6770    let (cr, _) = ed.cursor();
6771    let cc = ed.vim.block_vcol;
6772    let top = ar.min(cr);
6773    let bot = ar.max(cr);
6774    let left = ac.min(cc);
6775    let right = ac.max(cc);
6776    (top, bot, left, right)
6777}
6778
6779/// Update the virtual column after a motion in VisualBlock mode.
6780/// Horizontal motions sync `block_vcol` to the new cursor column;
6781/// vertical / non-h/l motions leave it alone so the intended column
6782/// survives clamping to shorter lines.
6783pub(crate) fn update_block_vcol<H: crate::types::Host>(
6784    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6785    motion: &Motion,
6786) {
6787    match motion {
6788        Motion::Left
6789        | Motion::Right
6790        | Motion::WordFwd
6791        | Motion::BigWordFwd
6792        | Motion::WordBack
6793        | Motion::BigWordBack
6794        | Motion::WordEnd
6795        | Motion::BigWordEnd
6796        | Motion::WordEndBack
6797        | Motion::BigWordEndBack
6798        | Motion::LineStart
6799        | Motion::FirstNonBlank
6800        | Motion::LineEnd
6801        | Motion::Find { .. }
6802        | Motion::FindRepeat { .. }
6803        | Motion::MatchBracket => {
6804            ed.vim.block_vcol = ed.cursor().1;
6805        }
6806        // Up / Down / FileTop / FileBottom / Search — preserve vcol.
6807        _ => {}
6808    }
6809}
6810
6811/// Yank / delete / change / replace a rectangular selection. Yanked text
6812/// is stored as one string per row joined with `\n` so pasting reproduces
6813/// the block as sequential lines. (Vim's true block-paste reinserts as
6814/// columns; we render the content with our char-wise paste path.)
6815fn apply_block_operator<H: crate::types::Host>(
6816    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6817    op: Operator,
6818    count: usize,
6819) {
6820    let (top, bot, left, right) = block_bounds(ed);
6821    // Snapshot the block text for yank / clipboard.
6822    let yank = block_yank(ed, top, bot, left, right);
6823
6824    match op {
6825        Operator::Yank => {
6826            if !yank.is_empty() {
6827                ed.record_yank_to_host(yank.clone());
6828                ed.record_yank(yank, false);
6829            }
6830            ed.vim.mode = Mode::Normal;
6831            ed.jump_cursor(top, left);
6832        }
6833        Operator::Delete => {
6834            ed.push_undo();
6835            delete_block_contents(ed, top, bot, left, right);
6836            if !yank.is_empty() {
6837                ed.record_yank_to_host(yank.clone());
6838                ed.record_delete(yank, false);
6839            }
6840            ed.vim.mode = Mode::Normal;
6841            ed.jump_cursor(top, left);
6842        }
6843        Operator::Change => {
6844            ed.push_undo();
6845            delete_block_contents(ed, top, bot, left, right);
6846            if !yank.is_empty() {
6847                ed.record_yank_to_host(yank.clone());
6848                ed.record_delete(yank, false);
6849            }
6850            ed.jump_cursor(top, left);
6851            begin_insert_noundo(
6852                ed,
6853                1,
6854                InsertReason::BlockChange {
6855                    top,
6856                    bot,
6857                    col: left,
6858                },
6859            );
6860        }
6861        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {
6862            ed.push_undo();
6863            transform_block_case(ed, op, top, bot, left, right);
6864            ed.vim.mode = Mode::Normal;
6865            ed.jump_cursor(top, left);
6866        }
6867        Operator::Indent | Operator::Outdent => {
6868            // VisualBlock `>` / `<` falls back to linewise indent over
6869            // the block's row range — vim does the same (column-wise
6870            // indent/outdent doesn't make sense).
6871            ed.push_undo();
6872            if op == Operator::Indent {
6873                indent_rows(ed, top, bot, count.max(1));
6874            } else {
6875                outdent_rows(ed, top, bot, count.max(1));
6876            }
6877            ed.vim.mode = Mode::Normal;
6878        }
6879        Operator::Fold => unreachable!("Visual zf takes its own path"),
6880        Operator::Reflow => {
6881            // Reflow over the block falls back to linewise reflow over
6882            // the row range — column slicing for `gq` doesn't make
6883            // sense.
6884            ed.push_undo();
6885            reflow_rows(ed, top, bot);
6886            ed.vim.mode = Mode::Normal;
6887        }
6888        Operator::ReflowKeepCursor => {
6889            // `gw` over a block: same fallback as `gq` but restore cursor.
6890            let saved = ed.cursor();
6891            ed.push_undo();
6892            let (before, after) = reflow_rows_keep_cursor(ed, top, bot);
6893            let (new_row, new_col) = reflow_keep_cursor(top, saved.0, saved.1, &before, &after);
6894            buf_set_cursor_rc(&mut ed.buffer, new_row, new_col);
6895            ed.push_buffer_cursor_to_textarea();
6896            ed.vim.mode = Mode::Normal;
6897        }
6898        Operator::AutoIndent => {
6899            // AutoIndent over the block falls back to linewise
6900            // auto-indent over the row range.
6901            ed.push_undo();
6902            auto_indent_rows(ed, top, bot);
6903            ed.vim.mode = Mode::Normal;
6904        }
6905        // Filter is dispatched through Editor::filter_range, not here.
6906        Operator::Filter => {}
6907        // Comment is dispatched through the app layer (engine_actions.rs), not here.
6908        Operator::Comment => {}
6909    }
6910}
6911
6912/// In-place case transform over the rectangular block
6913/// `(top..=bot, left..=right)`. Rows shorter than `left` are left
6914/// untouched — vim behaves the same way (ragged blocks).
6915fn transform_block_case<H: crate::types::Host>(
6916    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6917    op: Operator,
6918    top: usize,
6919    bot: usize,
6920    left: usize,
6921    right: usize,
6922) {
6923    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
6924    for r in top..=bot.min(lines.len().saturating_sub(1)) {
6925        let chars: Vec<char> = lines[r].chars().collect();
6926        if left >= chars.len() {
6927            continue;
6928        }
6929        let end = (right + 1).min(chars.len());
6930        let head: String = chars[..left].iter().collect();
6931        let mid: String = chars[left..end].iter().collect();
6932        let tail: String = chars[end..].iter().collect();
6933        let transformed = match op {
6934            Operator::Uppercase => mid.to_uppercase(),
6935            Operator::Lowercase => mid.to_lowercase(),
6936            Operator::ToggleCase => toggle_case_str(&mid),
6937            Operator::Rot13 => rot13_str(&mid),
6938            _ => mid,
6939        };
6940        lines[r] = format!("{head}{transformed}{tail}");
6941    }
6942    let saved_yank = ed.yank().to_string();
6943    let saved_linewise = ed.vim.yank_linewise;
6944    ed.restore(lines, (top, left));
6945    ed.set_yank(saved_yank);
6946    ed.vim.yank_linewise = saved_linewise;
6947}
6948
6949fn block_yank<H: crate::types::Host>(
6950    ed: &Editor<hjkl_buffer::Buffer, H>,
6951    top: usize,
6952    bot: usize,
6953    left: usize,
6954    right: usize,
6955) -> String {
6956    let rope = crate::types::Query::rope(&ed.buffer);
6957    let n = rope.len_lines();
6958    let mut rows: Vec<String> = Vec::new();
6959    for r in top..=bot {
6960        if r >= n {
6961            break;
6962        }
6963        let line = rope_line_to_str(&rope, r);
6964        let chars: Vec<char> = line.chars().collect();
6965        let end = (right + 1).min(chars.len());
6966        if left >= chars.len() {
6967            rows.push(String::new());
6968        } else {
6969            rows.push(chars[left..end].iter().collect());
6970        }
6971    }
6972    rows.join("\n")
6973}
6974
6975fn delete_block_contents<H: crate::types::Host>(
6976    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6977    top: usize,
6978    bot: usize,
6979    left: usize,
6980    right: usize,
6981) {
6982    use hjkl_buffer::{Edit, MotionKind, Position};
6983    ed.sync_buffer_content_from_textarea();
6984    let last_row = bot.min(buf_row_count(&ed.buffer).saturating_sub(1));
6985    if last_row < top {
6986        return;
6987    }
6988    ed.mutate_edit(Edit::DeleteRange {
6989        start: Position::new(top, left),
6990        end: Position::new(last_row, right),
6991        kind: MotionKind::Block,
6992    });
6993    ed.push_buffer_cursor_to_textarea();
6994}
6995
6996/// Replace each character cell in the block with `ch`.
6997pub(crate) fn block_replace<H: crate::types::Host>(
6998    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6999    ch: char,
7000) {
7001    let (top, bot, left, right) = block_bounds(ed);
7002    ed.push_undo();
7003    ed.sync_buffer_content_from_textarea();
7004    let mut lines: Vec<String> = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
7005    for r in top..=bot.min(lines.len().saturating_sub(1)) {
7006        let chars: Vec<char> = lines[r].chars().collect();
7007        if left >= chars.len() {
7008            continue;
7009        }
7010        let end = (right + 1).min(chars.len());
7011        let before: String = chars[..left].iter().collect();
7012        let middle: String = std::iter::repeat_n(ch, end - left).collect();
7013        let after: String = chars[end..].iter().collect();
7014        lines[r] = format!("{before}{middle}{after}");
7015    }
7016    reset_textarea_lines(ed, lines);
7017    ed.vim.mode = Mode::Normal;
7018    ed.jump_cursor(top, left);
7019}
7020
7021/// Replace buffer content with `lines` while preserving the cursor.
7022/// Used by indent / outdent / block_replace to wholesale rewrite
7023/// rows without going through the per-edit funnel.
7024fn reset_textarea_lines<H: crate::types::Host>(
7025    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7026    lines: Vec<String>,
7027) {
7028    let cursor = ed.cursor();
7029    crate::types::BufferEdit::replace_all(&mut ed.buffer, &lines.join("\n"));
7030    buf_set_cursor_rc(&mut ed.buffer, cursor.0, cursor.1);
7031    ed.mark_content_dirty();
7032}
7033
7034// ─── Visual-line helpers ───────────────────────────────────────────────────
7035
7036// ─── Text-object range computation ─────────────────────────────────────────
7037
7038/// Cursor position as `(row, col)`.
7039type Pos = (usize, usize);
7040
7041/// Returns `(start, end, kind)` where `end` is *exclusive* (one past the
7042/// last character to act on). `kind` is `Linewise` for line-oriented text
7043/// objects like paragraphs and `Exclusive` otherwise.
7044pub(crate) fn text_object_range<H: crate::types::Host>(
7045    ed: &Editor<hjkl_buffer::Buffer, H>,
7046    obj: TextObject,
7047    inner: bool,
7048    count: usize,
7049) -> Option<(Pos, Pos, RangeKind)> {
7050    match obj {
7051        TextObject::Word { big } => {
7052            word_text_object(ed, inner, big).map(|(s, e)| (s, e, RangeKind::Exclusive))
7053        }
7054        TextObject::Quote(q) => {
7055            quote_text_object(ed, q, inner).map(|(s, e)| (s, e, RangeKind::Exclusive))
7056        }
7057        TextObject::Bracket(open) => bracket_text_object(ed, open, inner, count),
7058        TextObject::Paragraph => {
7059            paragraph_text_object(ed, inner).map(|(s, e)| (s, e, RangeKind::Linewise))
7060        }
7061        TextObject::XmlTag => tag_text_object(ed, inner).map(|(s, e)| (s, e, RangeKind::Exclusive)),
7062        TextObject::Sentence => {
7063            sentence_text_object(ed, inner).map(|(s, e)| (s, e, RangeKind::Exclusive))
7064        }
7065    }
7066}
7067
7068/// `(` / `)` — walk to the next sentence boundary in `forward` direction.
7069/// Returns `(row, col)` of the boundary's first non-whitespace cell, or
7070/// `None` when already at the buffer's edge in that direction.
7071fn sentence_boundary<H: crate::types::Host>(
7072    ed: &Editor<hjkl_buffer::Buffer, H>,
7073    forward: bool,
7074) -> Option<(usize, usize)> {
7075    let rope = crate::types::Query::rope(&ed.buffer);
7076    let n_lines = rope.len_lines();
7077    if n_lines == 0 {
7078        return None;
7079    }
7080    // Per-line char counts (excluding trailing \n) for pos↔idx conversion.
7081    let line_lens: Vec<usize> = (0..n_lines)
7082        .map(|r| rope_line_to_str(&rope, r).chars().count())
7083        .collect();
7084    let pos_to_idx = |pos: (usize, usize)| -> usize {
7085        let idx: usize = line_lens.iter().take(pos.0).map(|&len| len + 1).sum();
7086        idx + pos.1
7087    };
7088    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
7089        for (r, &len) in line_lens.iter().enumerate() {
7090            if idx <= len {
7091                return (r, idx);
7092            }
7093            idx -= len + 1;
7094        }
7095        let last = n_lines.saturating_sub(1);
7096        (last, line_lens[last])
7097    };
7098    // Build flat char vector: rope chars already include \n between lines.
7099    // ropey's last line has no trailing \n; intermediate ones do.
7100    let mut chars: Vec<char> = rope.chars().collect();
7101    // Strip a trailing \n if ropey emitted one on the final line.
7102    if chars.last() == Some(&'\n') {
7103        chars.pop();
7104    }
7105    if chars.is_empty() {
7106        return None;
7107    }
7108    let total = chars.len();
7109    let cursor_idx = pos_to_idx(ed.cursor()).min(total - 1);
7110    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
7111
7112    if forward {
7113        // Walk forward looking for a terminator run followed by
7114        // whitespace; land on the first non-whitespace cell after.
7115        let mut i = cursor_idx + 1;
7116        while i < total {
7117            if is_terminator(chars[i]) {
7118                while i + 1 < total && is_terminator(chars[i + 1]) {
7119                    i += 1;
7120                }
7121                if i + 1 >= total {
7122                    return None;
7123                }
7124                if chars[i + 1].is_whitespace() {
7125                    let mut j = i + 1;
7126                    while j < total && chars[j].is_whitespace() {
7127                        j += 1;
7128                    }
7129                    if j >= total {
7130                        return None;
7131                    }
7132                    return Some(idx_to_pos(j));
7133                }
7134            }
7135            i += 1;
7136        }
7137        None
7138    } else {
7139        // Walk backward to find the start of the current sentence (if
7140        // we're already at the start, jump to the previous sentence's
7141        // start instead).
7142        let find_start = |from: usize| -> Option<usize> {
7143            let mut start = from;
7144            while start > 0 {
7145                let prev = chars[start - 1];
7146                if prev.is_whitespace() {
7147                    let mut k = start - 1;
7148                    while k > 0 && chars[k - 1].is_whitespace() {
7149                        k -= 1;
7150                    }
7151                    if k > 0 && is_terminator(chars[k - 1]) {
7152                        break;
7153                    }
7154                }
7155                start -= 1;
7156            }
7157            while start < total && chars[start].is_whitespace() {
7158                start += 1;
7159            }
7160            (start < total).then_some(start)
7161        };
7162        let current_start = find_start(cursor_idx)?;
7163        if current_start < cursor_idx {
7164            return Some(idx_to_pos(current_start));
7165        }
7166        // Already at the sentence start — step over the boundary into
7167        // the previous sentence and find its start.
7168        let mut k = current_start;
7169        while k > 0 && chars[k - 1].is_whitespace() {
7170            k -= 1;
7171        }
7172        if k == 0 {
7173            return None;
7174        }
7175        let prev_start = find_start(k - 1)?;
7176        Some(idx_to_pos(prev_start))
7177    }
7178}
7179
7180/// `is` / `as` — sentence: text up to and including the next sentence
7181/// terminator (`.`, `?`, `!`). Vim treats `.`/`?`/`!` followed by
7182/// whitespace (or end-of-line) as a boundary; runs of consecutive
7183/// terminators stay attached to the same sentence. `as` extends to
7184/// include trailing whitespace; `is` does not.
7185fn sentence_text_object<H: crate::types::Host>(
7186    ed: &Editor<hjkl_buffer::Buffer, H>,
7187    inner: bool,
7188) -> Option<((usize, usize), (usize, usize))> {
7189    let rope = crate::types::Query::rope(&ed.buffer);
7190    let n_lines = rope.len_lines();
7191    if n_lines == 0 {
7192        return None;
7193    }
7194    // Flatten the buffer so a sentence can span lines (vim's behaviour).
7195    // Newlines count as whitespace for boundary detection.
7196    let line_lens: Vec<usize> = (0..n_lines)
7197        .map(|r| rope_line_to_str(&rope, r).chars().count())
7198        .collect();
7199    let pos_to_idx = |pos: (usize, usize)| -> usize {
7200        let idx: usize = line_lens.iter().take(pos.0).map(|&len| len + 1).sum();
7201        idx + pos.1
7202    };
7203    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
7204        for (r, &len) in line_lens.iter().enumerate() {
7205            if idx <= len {
7206                return (r, idx);
7207            }
7208            idx -= len + 1;
7209        }
7210        let last = n_lines.saturating_sub(1);
7211        (last, line_lens[last])
7212    };
7213    let mut chars: Vec<char> = rope.chars().collect();
7214    if chars.last() == Some(&'\n') {
7215        chars.pop();
7216    }
7217    if chars.is_empty() {
7218        return None;
7219    }
7220
7221    let cursor_idx = pos_to_idx(ed.cursor()).min(chars.len() - 1);
7222    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
7223
7224    // Walk backward from cursor to find the start of the current
7225    // sentence. A boundary is: whitespace immediately after a run of
7226    // terminators (or start-of-buffer).
7227    let mut start = cursor_idx;
7228    while start > 0 {
7229        let prev = chars[start - 1];
7230        if prev.is_whitespace() {
7231            // Check if the whitespace follows a terminator — if so,
7232            // we've crossed a sentence boundary; the sentence begins
7233            // at the first non-whitespace cell *after* this run.
7234            let mut k = start - 1;
7235            while k > 0 && chars[k - 1].is_whitespace() {
7236                k -= 1;
7237            }
7238            if k > 0 && is_terminator(chars[k - 1]) {
7239                break;
7240            }
7241        }
7242        start -= 1;
7243    }
7244    // Skip leading whitespace (vim doesn't include it in the
7245    // sentence body).
7246    while start < chars.len() && chars[start].is_whitespace() {
7247        start += 1;
7248    }
7249    if start >= chars.len() {
7250        return None;
7251    }
7252
7253    // Walk forward to the sentence end (last terminator before the
7254    // next whitespace boundary).
7255    let mut end = start;
7256    while end < chars.len() {
7257        if is_terminator(chars[end]) {
7258            // Consume any consecutive terminators (e.g. `?!`).
7259            while end + 1 < chars.len() && is_terminator(chars[end + 1]) {
7260                end += 1;
7261            }
7262            // If followed by whitespace or end-of-buffer, that's the
7263            // boundary.
7264            if end + 1 >= chars.len() || chars[end + 1].is_whitespace() {
7265                break;
7266            }
7267        }
7268        end += 1;
7269    }
7270    // Inclusive end → exclusive end_idx.
7271    let end_idx = (end + 1).min(chars.len());
7272
7273    let final_end = if inner {
7274        end_idx
7275    } else {
7276        // `as`: include trailing whitespace (but stop before the next
7277        // newline so we don't gobble a paragraph break — vim keeps
7278        // sentences within a paragraph for the trailing-ws extension).
7279        let mut e = end_idx;
7280        while e < chars.len() && chars[e].is_whitespace() && chars[e] != '\n' {
7281            e += 1;
7282        }
7283        e
7284    };
7285
7286    Some((idx_to_pos(start), idx_to_pos(final_end)))
7287}
7288
7289/// `it` / `at` — XML tag pair text object. Builds a flat char index of
7290/// the buffer, walks `<...>` tokens to pair tags via a stack, and
7291/// returns the innermost pair containing the cursor.
7292fn tag_text_object<H: crate::types::Host>(
7293    ed: &Editor<hjkl_buffer::Buffer, H>,
7294    inner: bool,
7295) -> Option<((usize, usize), (usize, usize))> {
7296    let rope = crate::types::Query::rope(&ed.buffer);
7297    let n_lines = rope.len_lines();
7298    if n_lines == 0 {
7299        return None;
7300    }
7301    // Flatten char positions so we can compare cursor against tag
7302    // ranges without per-row arithmetic. `\n` between lines counts as
7303    // a single char.
7304    let line_lens: Vec<usize> = (0..n_lines)
7305        .map(|r| rope_line_to_str(&rope, r).chars().count())
7306        .collect();
7307    let pos_to_idx = |pos: (usize, usize)| -> usize {
7308        let idx: usize = line_lens.iter().take(pos.0).map(|&len| len + 1).sum();
7309        idx + pos.1
7310    };
7311    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
7312        for (r, &len) in line_lens.iter().enumerate() {
7313            if idx <= len {
7314                return (r, idx);
7315            }
7316            idx -= len + 1;
7317        }
7318        let last = n_lines.saturating_sub(1);
7319        (last, line_lens[last])
7320    };
7321    let mut chars: Vec<char> = rope.chars().collect();
7322    if chars.last() == Some(&'\n') {
7323        chars.pop();
7324    }
7325    let cursor_idx = pos_to_idx(ed.cursor());
7326
7327    // Walk `<...>` tokens. Track open tags on a stack; on a matching
7328    // close pop and consider the pair a candidate when the cursor lies
7329    // inside its content range. Innermost wins (replace whenever a
7330    // tighter range turns up). Also track the first complete pair that
7331    // starts at or after the cursor so we can fall back to a forward
7332    // scan (targets.vim-style) when the cursor isn't inside any tag.
7333    let mut stack: Vec<(usize, usize, String)> = Vec::new(); // (open_start, content_start, name)
7334    let mut innermost: Option<(usize, usize, usize, usize)> = None;
7335    let mut next_after: Option<(usize, usize, usize, usize)> = None;
7336    let mut i = 0;
7337    while i < chars.len() {
7338        if chars[i] != '<' {
7339            i += 1;
7340            continue;
7341        }
7342        let mut j = i + 1;
7343        while j < chars.len() && chars[j] != '>' {
7344            j += 1;
7345        }
7346        if j >= chars.len() {
7347            break;
7348        }
7349        let inside: String = chars[i + 1..j].iter().collect();
7350        let close_end = j + 1;
7351        let trimmed = inside.trim();
7352        if trimmed.starts_with('!') || trimmed.starts_with('?') {
7353            i = close_end;
7354            continue;
7355        }
7356        if let Some(rest) = trimmed.strip_prefix('/') {
7357            let name = rest.split_whitespace().next().unwrap_or("").to_string();
7358            if !name.is_empty()
7359                && let Some(stack_idx) = stack.iter().rposition(|(_, _, n)| *n == name)
7360            {
7361                let (open_start, content_start, _) = stack[stack_idx].clone();
7362                stack.truncate(stack_idx);
7363                let content_end = i;
7364                let candidate = (open_start, content_start, content_end, close_end);
7365                // A pair encloses the cursor when the cursor lies anywhere
7366                // within the whole pair span — including ON the open or close
7367                // tag itself (vim `it`/`at` operate on the tag under the
7368                // cursor, not just its content). Innermost (tightest span)
7369                // wins; closes are seen innermost-first so the first enclosing
7370                // candidate is already the tightest.
7371                if cursor_idx >= open_start && cursor_idx < close_end {
7372                    innermost = match innermost {
7373                        Some((os, _, _, ce)) if os <= open_start && close_end <= ce => {
7374                            Some(candidate)
7375                        }
7376                        None => Some(candidate),
7377                        existing => existing,
7378                    };
7379                } else if open_start >= cursor_idx && next_after.is_none() {
7380                    next_after = Some(candidate);
7381                }
7382            }
7383        } else if !trimmed.ends_with('/') {
7384            let name: String = trimmed
7385                .split(|c: char| c.is_whitespace() || c == '/')
7386                .next()
7387                .unwrap_or("")
7388                .to_string();
7389            if !name.is_empty() {
7390                stack.push((i, close_end, name));
7391            }
7392        }
7393        i = close_end;
7394    }
7395
7396    let (open_start, content_start, content_end, close_end) = innermost.or(next_after)?;
7397    if inner {
7398        Some((idx_to_pos(content_start), idx_to_pos(content_end)))
7399    } else {
7400        Some((idx_to_pos(open_start), idx_to_pos(close_end)))
7401    }
7402}
7403
7404fn is_wordchar(c: char) -> bool {
7405    c.is_alphanumeric() || c == '_'
7406}
7407
7408// `is_keyword_char` lives in hjkl-buffer (used by word motions);
7409// engine re-uses it via `hjkl_buffer::is_keyword_char` so there's
7410// one parser, one default, one bug surface.
7411pub(crate) use hjkl_buffer::is_keyword_char;
7412
7413/// Classify a vim abbreviation lhs into its type.
7414///
7415/// - **Full**: every char in `lhs` is a keyword char (full-id).
7416/// - **End**: the last char is a keyword char, at least one other is not (end-id).
7417/// - **None**: the last char is a non-keyword char (non-id).
7418#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7419pub(crate) enum AbbrevKind {
7420    /// All keyword chars (full-id).
7421    Full,
7422    /// Last char keyword, others include non-keyword (end-id).
7423    End,
7424    /// Last char is non-keyword (non-id).
7425    NonKw,
7426}
7427
7428pub(crate) fn abbrev_kind(lhs: &str, iskeyword: &str) -> AbbrevKind {
7429    let chars: Vec<char> = lhs.chars().collect();
7430    if chars.is_empty() {
7431        return AbbrevKind::NonKw;
7432    }
7433    let last = *chars.last().unwrap();
7434    let last_is_kw = is_keyword_char(last, iskeyword);
7435    if !last_is_kw {
7436        return AbbrevKind::NonKw;
7437    }
7438    // last is keyword — check if all chars are keyword
7439    let all_kw = chars.iter().all(|&c| is_keyword_char(c, iskeyword));
7440    if all_kw {
7441        AbbrevKind::Full
7442    } else {
7443        AbbrevKind::End
7444    }
7445}
7446
7447/// Try to match and expand an abbreviation given the text before the cursor.
7448///
7449/// # Parameters
7450/// - `abbrevs` — the active abbreviation table (insert-mode entries).
7451/// - `line_before` — the text on the current line *before* the cursor (char slice).
7452/// - `mincol` — first column index (0-based, char-indexed) that belongs to the
7453///   current insert session on the **same row as the cursor**.  Chars before
7454///   `mincol` were in the buffer before insert mode started and must NOT be
7455///   consumed as part of the lhs.  When the cursor is on a different row than
7456///   `start_row`, `mincol` is treated as 0 (the entire line was typed in this
7457///   session).
7458/// - `trigger` — what the user did (typed a non-kw char, pressed CR/Esc/C-]).
7459/// - `iskeyword` — the active iskeyword spec string.
7460///
7461/// Returns `Some((lhs_char_len, rhs))` on a match, where `lhs_char_len` is the
7462/// number of characters to delete before the cursor (the lhs), and `rhs` is the
7463/// text to insert in their place.  Returns `None` when no abbreviation matches.
7464pub(crate) fn try_abbrev_expand(
7465    abbrevs: &[Abbrev],
7466    line_before: &str,
7467    mincol: usize,
7468    trigger: AbbrevTrigger,
7469    iskeyword: &str,
7470) -> Option<(usize, String)> {
7471    let chars: Vec<char> = line_before.chars().collect();
7472    let cursor_col = chars.len(); // col of the cursor (0-based)
7473
7474    for abbrev in abbrevs {
7475        if !abbrev.insert {
7476            continue;
7477        }
7478        let lhs_chars: Vec<char> = abbrev.lhs.chars().collect();
7479        if lhs_chars.is_empty() {
7480            continue;
7481        }
7482        let lhs_len = lhs_chars.len();
7483
7484        // Determine the lhs type.
7485        let kind = abbrev_kind(&abbrev.lhs, iskeyword);
7486
7487        // Trigger rules by lhs type.
7488        match kind {
7489            AbbrevKind::Full | AbbrevKind::End => {
7490                // full-id / end-id: trigger char must be a NON-keyword char
7491                // (space, punctuation, CR, Esc, C-]).
7492                let trigger_char_is_kw = match trigger {
7493                    AbbrevTrigger::NonKeyword(c) => is_keyword_char(c, iskeyword),
7494                    AbbrevTrigger::CtrlBracket | AbbrevTrigger::Cr | AbbrevTrigger::Esc => false,
7495                };
7496                if trigger_char_is_kw {
7497                    // A keyword trigger char would extend the word — no expand.
7498                    continue;
7499                }
7500            }
7501            AbbrevKind::NonKw => {
7502                // non-id: only expand on CR, Esc, C-].  NOT on regular typed chars.
7503                match trigger {
7504                    AbbrevTrigger::Cr | AbbrevTrigger::Esc | AbbrevTrigger::CtrlBracket => {}
7505                    AbbrevTrigger::NonKeyword(_) => continue,
7506                }
7507            }
7508        }
7509
7510        // Check that the text before the cursor ends with the lhs.
7511        if cursor_col < lhs_len {
7512            continue;
7513        }
7514        let lhs_start_col = cursor_col - lhs_len;
7515
7516        // Enforce mincol: the lhs must not start before the insert-start column.
7517        if lhs_start_col < mincol {
7518            continue;
7519        }
7520
7521        // Compare chars.
7522        let text_slice: &[char] = &chars[lhs_start_col..cursor_col];
7523        if text_slice != lhs_chars.as_slice() {
7524            continue;
7525        }
7526
7527        // Check "front" rule: the char immediately before the lhs.
7528        if lhs_start_col > 0 {
7529            let ch_before = chars[lhs_start_col - 1];
7530            match kind {
7531                AbbrevKind::Full => {
7532                    // full-id: char before lhs must be a non-keyword char.
7533                    // Single-char full-id exception: if the char before is a
7534                    // non-keyword char that is NOT space/tab, it is NOT recognised
7535                    // (vim `:h abbreviations`: "A word in front of a full-id abbrev
7536                    // is a non-keyword char; but a single char abbrev is not
7537                    // recognised after a non-blank, non-keyword char").
7538                    // Actually vim's rule: full-id is not recognised if the char
7539                    // before is a NON-keyword char other than space/tab AND the lhs
7540                    // is a single keyword char. For multi-char full-id the rule is
7541                    // just "char before must be non-keyword".
7542                    if is_keyword_char(ch_before, iskeyword) {
7543                        continue; // char before is keyword → lhs is part of a longer word
7544                    }
7545                    if lhs_len == 1 && ch_before != ' ' && ch_before != '\t' {
7546                        // single-char full-id: non-blank non-keyword before → skip
7547                        continue;
7548                    }
7549                }
7550                AbbrevKind::End => {
7551                    // end-id: no constraint on the char before (any char is fine,
7552                    // including keyword chars — the non-keyword prefix of the lhs
7553                    // acts as the boundary).
7554                }
7555                AbbrevKind::NonKw => {
7556                    // non-id: the char before the lhs must be blank (space/tab) or
7557                    // it must be the start of the typed portion (mincol boundary).
7558                    if ch_before != ' ' && ch_before != '\t' {
7559                        continue;
7560                    }
7561                }
7562            }
7563        }
7564        // lhs_start_col == 0 means the lhs starts at the very beginning of the
7565        // line (or at the insert-start position); all types accept this.
7566
7567        return Some((lhs_len, abbrev.rhs.clone()));
7568    }
7569
7570    None
7571}
7572
7573/// Check abbreviations and apply the expansion if a match is found.
7574///
7575/// Reads the current cursor position and the text before it, calls
7576/// `try_abbrev_expand`, and if a match is found, deletes the `lhs` chars
7577/// and inserts the `rhs`. Returns `true` if an expansion was applied.
7578///
7579/// `trigger` is what the user did; the trigger char itself is NOT inserted
7580/// here — the caller inserts it (or not, in the case of `C-]`).
7581pub(crate) fn check_and_apply_abbrev<H: crate::types::Host>(
7582    ed: &mut Editor<hjkl_buffer::Buffer, H>,
7583    trigger: AbbrevTrigger,
7584) -> bool {
7585    use hjkl_buffer::{Edit, Position};
7586
7587    // Collect the data we need without holding borrows.
7588    let cursor = buf_cursor_pos(&ed.buffer);
7589    let row = cursor.row;
7590    let col = cursor.col;
7591    let line_before: String = {
7592        let line = buf_line(&ed.buffer, row).unwrap_or_default();
7593        line.chars().take(col).collect()
7594    };
7595    let (mincol, on_start_row) = if let Some(ref s) = ed.vim.insert_session {
7596        if row == s.start_row {
7597            (s.start_col, true)
7598        } else {
7599            (0, false)
7600        }
7601    } else {
7602        (0, false)
7603    };
7604    // If cursor is before the insert start column on the same row, no lhs possible.
7605    if on_start_row && col <= mincol {
7606        return false;
7607    }
7608
7609    let iskeyword = ed.settings.iskeyword.clone();
7610    let abbrevs = ed.vim.abbrevs.clone();
7611
7612    let Some((lhs_len, rhs)) =
7613        try_abbrev_expand(&abbrevs, &line_before, mincol, trigger, &iskeyword)
7614    else {
7615        return false;
7616    };
7617
7618    // Delete `lhs_len` chars before the cursor.
7619    let lhs_start = col.saturating_sub(lhs_len);
7620    if lhs_len > 0 {
7621        ed.mutate_edit(Edit::DeleteRange {
7622            start: Position::new(row, lhs_start),
7623            end: Position::new(row, col),
7624            kind: hjkl_buffer::MotionKind::Char,
7625        });
7626    }
7627
7628    // Insert rhs at the (now updated) cursor position.
7629    let insert_pos = Position::new(row, lhs_start);
7630    if !rhs.is_empty() {
7631        ed.mutate_edit(Edit::InsertStr {
7632            at: insert_pos,
7633            text: rhs.clone(),
7634        });
7635    }
7636
7637    // Move cursor to end of inserted rhs.
7638    let new_col = lhs_start + rhs.chars().count();
7639    buf_set_cursor_rc(&mut ed.buffer, row, new_col);
7640    ed.push_buffer_cursor_to_textarea();
7641
7642    true
7643}
7644
7645fn word_text_object<H: crate::types::Host>(
7646    ed: &Editor<hjkl_buffer::Buffer, H>,
7647    inner: bool,
7648    big: bool,
7649) -> Option<((usize, usize), (usize, usize))> {
7650    let (row, col) = ed.cursor();
7651    let line = buf_line(&ed.buffer, row)?;
7652    let chars: Vec<char> = line.chars().collect();
7653    if chars.is_empty() {
7654        return None;
7655    }
7656    let at = col.min(chars.len().saturating_sub(1));
7657    let classify = |c: char| -> u8 {
7658        if c.is_whitespace() {
7659            0
7660        } else if big || is_wordchar(c) {
7661            1
7662        } else {
7663            2
7664        }
7665    };
7666    let cls = classify(chars[at]);
7667    let mut start = at;
7668    while start > 0 && classify(chars[start - 1]) == cls {
7669        start -= 1;
7670    }
7671    let mut end = at;
7672    while end + 1 < chars.len() && classify(chars[end + 1]) == cls {
7673        end += 1;
7674    }
7675    // Byte-offset helpers.
7676    let char_byte = |i: usize| {
7677        if i >= chars.len() {
7678            line.len()
7679        } else {
7680            line.char_indices().nth(i).map(|(b, _)| b).unwrap_or(0)
7681        }
7682    };
7683    let mut start_col = char_byte(start);
7684    // Exclusive end: byte index of char AFTER the last-included char.
7685    let mut end_col = char_byte(end + 1);
7686    if !inner {
7687        // `aw` — include trailing whitespace; if there's no trailing ws, absorb leading ws.
7688        let mut t = end + 1;
7689        let mut included_trailing = false;
7690        while t < chars.len() && chars[t].is_whitespace() {
7691            included_trailing = true;
7692            t += 1;
7693        }
7694        if included_trailing {
7695            end_col = char_byte(t);
7696        } else {
7697            let mut s = start;
7698            while s > 0 && chars[s - 1].is_whitespace() {
7699                s -= 1;
7700            }
7701            start_col = char_byte(s);
7702        }
7703    }
7704    Some(((row, start_col), (row, end_col)))
7705}
7706
7707fn quote_text_object<H: crate::types::Host>(
7708    ed: &Editor<hjkl_buffer::Buffer, H>,
7709    q: char,
7710    inner: bool,
7711) -> Option<((usize, usize), (usize, usize))> {
7712    let (row, col) = ed.cursor();
7713    let line = buf_line(&ed.buffer, row)?;
7714    let bytes = line.as_bytes();
7715    let q_byte = q as u8;
7716    // Find opening and closing quote on the same line.
7717    let mut positions: Vec<usize> = Vec::new();
7718    for (i, &b) in bytes.iter().enumerate() {
7719        if b == q_byte {
7720            positions.push(i);
7721        }
7722    }
7723    if positions.len() < 2 {
7724        return None;
7725    }
7726    let mut open_idx: Option<usize> = None;
7727    let mut close_idx: Option<usize> = None;
7728    for pair in positions.chunks(2) {
7729        if pair.len() < 2 {
7730            break;
7731        }
7732        if col >= pair[0] && col <= pair[1] {
7733            open_idx = Some(pair[0]);
7734            close_idx = Some(pair[1]);
7735            break;
7736        }
7737        if col < pair[0] {
7738            open_idx = Some(pair[0]);
7739            close_idx = Some(pair[1]);
7740            break;
7741        }
7742    }
7743    let open = open_idx?;
7744    let close = close_idx?;
7745    // End columns are *exclusive* — one past the last character to act on.
7746    if inner {
7747        if close <= open + 1 {
7748            return None;
7749        }
7750        Some(((row, open + 1), (row, close)))
7751    } else {
7752        // `da<q>` — "around" includes the surrounding whitespace on one
7753        // side: trailing whitespace if any exists after the closing quote;
7754        // otherwise leading whitespace before the opening quote. This
7755        // matches vim's `:help text-objects` behaviour and avoids leaving
7756        // a double-space when the quoted span sits mid-sentence.
7757        let after_close = close + 1; // byte index after closing quote
7758        if after_close < bytes.len() && bytes[after_close].is_ascii_whitespace() {
7759            // Eat trailing whitespace run.
7760            let mut end = after_close;
7761            while end < bytes.len() && bytes[end].is_ascii_whitespace() {
7762                end += 1;
7763            }
7764            Some(((row, open), (row, end)))
7765        } else if open > 0 && bytes[open - 1].is_ascii_whitespace() {
7766            // Eat leading whitespace run.
7767            let mut start = open;
7768            while start > 0 && bytes[start - 1].is_ascii_whitespace() {
7769                start -= 1;
7770            }
7771            Some(((row, start), (row, close + 1)))
7772        } else {
7773            Some(((row, open), (row, close + 1)))
7774        }
7775    }
7776}
7777
7778fn bracket_text_object<H: crate::types::Host>(
7779    ed: &Editor<hjkl_buffer::Buffer, H>,
7780    open: char,
7781    inner: bool,
7782    count: usize,
7783) -> Option<(Pos, Pos, RangeKind)> {
7784    let close = match open {
7785        '(' => ')',
7786        '[' => ']',
7787        '{' => '}',
7788        '<' => '>',
7789        _ => return None,
7790    };
7791    let (row, col) = ed.cursor();
7792    let lines = rope_to_lines_vec(&crate::types::Query::rope(&ed.buffer));
7793    let lines = lines.as_slice();
7794    // If the cursor sits ON the closing bracket, vim anchors the pair to that
7795    // bracket: the close is at the cursor and the open is found by scanning
7796    // backward from just before it. Without this, `find_open_bracket` counts
7797    // the cursor's own close, increments depth, and skips past its matching
7798    // open — making `di}`/`di{`-on-`}` a silent no-op.
7799    let cursor_char = lines.get(row).and_then(|l| l.chars().nth(col));
7800    let (open_pos, close_pos) = if cursor_char == Some(close) {
7801        let open_pos = if col > 0 {
7802            find_open_bracket(lines, row, col - 1, open, close)
7803        } else if row > 0 {
7804            let pr = row - 1;
7805            let pc = lines[pr].chars().count().saturating_sub(1);
7806            find_open_bracket(lines, pr, pc, open, close)
7807        } else {
7808            None
7809        }?;
7810        (open_pos, (row, col))
7811    } else {
7812        // Walk backward from cursor to find unbalanced opening. When the
7813        // cursor isn't inside any pair, fall back to scanning forward for
7814        // the next opening bracket (targets.vim-style: `ci(` works when
7815        // cursor is before the `(` on the same line or below).
7816        let open_pos = find_open_bracket(lines, row, col, open, close)
7817            .or_else(|| find_next_open(lines, row, col, open))?;
7818        let close_pos = find_close_bracket(lines, open_pos.0, open_pos.1 + 1, open, close)?;
7819        (open_pos, close_pos)
7820    };
7821    // Count: `2i{` / `2a{` target the Nth enclosing pair. Expand outward from
7822    // the innermost pair, re-anchoring to each enclosing bracket in turn. Stop
7823    // early (and use the outermost found) if there aren't `count` levels.
7824    let (open_pos, close_pos) = {
7825        let (mut op, mut cp) = (open_pos, close_pos);
7826        for _ in 1..count.max(1) {
7827            let outer = if op.1 > 0 {
7828                find_open_bracket(lines, op.0, op.1 - 1, open, close)
7829            } else if op.0 > 0 {
7830                let pr = op.0 - 1;
7831                let pc = lines[pr].chars().count().saturating_sub(1);
7832                find_open_bracket(lines, pr, pc, open, close)
7833            } else {
7834                None
7835            };
7836            let Some(oo) = outer else { break };
7837            let Some(oc) = find_close_bracket(lines, oo.0, oo.1 + 1, open, close) else {
7838                break;
7839            };
7840            op = oo;
7841            cp = oc;
7842        }
7843        (op, cp)
7844    };
7845    // End positions are *exclusive*.
7846    if inner {
7847        // The inner region is the raw charwise span from just after `{` to just
7848        // before `}`. Returned as Exclusive: the VISUAL path uses it directly
7849        // (so `vi{` is charwise — `vi{d` → "{}"), while the OPERATOR path
7850        // (`di{`/`ci{`) applies vim's exclusive-motion adjustment in
7851        // `apply_op_with_text_object` to collapse a contentful multi-line block
7852        // to bare braces ("{\n}") or promote a clean one to linewise.
7853        // Inner start = position just after `{`. When `{` is the last char on
7854        // its line, the inner region begins at the start of the next line (so
7855        // the exclusive-motion adjustment can promote to linewise). `advance_pos`
7856        // stops at end-of-line, so wrap explicitly here.
7857        let open_line_len = lines[open_pos.0].chars().count();
7858        let inner_start = if open_pos.1 + 1 >= open_line_len && open_pos.0 + 1 < lines.len() {
7859            (open_pos.0 + 1, 0)
7860        } else {
7861            advance_pos(lines, open_pos)
7862        };
7863        // Empty inner (`{}` / `( )` degenerate) → empty range at the inner
7864        // start. `di{` then no-ops; `ci{` inserts at that point.
7865        if inner_start.0 > close_pos.0
7866            || (inner_start.0 == close_pos.0 && inner_start.1 >= close_pos.1)
7867        {
7868            return Some((inner_start, inner_start, RangeKind::Exclusive));
7869        }
7870        // Whitespace-only multi-line inner: vim's `di{` is a no-op and `ci{`
7871        // inserts at the inner start without deleting the whitespace. Model as
7872        // an empty range at the inner start. Detected when every char strictly
7873        // between the braces (excluding newlines) is a space/tab, and there is
7874        // at least one — an inner of only newlines (empty lines) does NOT count
7875        // and falls through to the normal collapse.
7876        if close_pos.0 > open_pos.0 {
7877            let mut saw_ws = false;
7878            let mut saw_other = false;
7879            for r in inner_start.0..=close_pos.0 {
7880                let line: Vec<char> = lines
7881                    .get(r)
7882                    .map(|l| l.chars().collect())
7883                    .unwrap_or_default();
7884                let from = if r == inner_start.0 { inner_start.1 } else { 0 };
7885                let to = if r == close_pos.0 {
7886                    close_pos.1
7887                } else {
7888                    line.len()
7889                };
7890                for &c in line
7891                    .iter()
7892                    .take(to.min(line.len()))
7893                    .skip(from.min(line.len()))
7894                {
7895                    if c == ' ' || c == '\t' {
7896                        saw_ws = true;
7897                    } else {
7898                        saw_other = true;
7899                    }
7900                }
7901            }
7902            if saw_ws && !saw_other {
7903                return Some((inner_start, inner_start, RangeKind::Exclusive));
7904            }
7905        }
7906        Some((inner_start, close_pos, RangeKind::Exclusive))
7907    } else {
7908        Some((
7909            open_pos,
7910            advance_pos(lines, close_pos),
7911            RangeKind::Exclusive,
7912        ))
7913    }
7914}
7915
7916fn find_open_bracket(
7917    lines: &[String],
7918    row: usize,
7919    col: usize,
7920    open: char,
7921    close: char,
7922) -> Option<(usize, usize)> {
7923    let mut depth: i32 = 0;
7924    let mut r = row;
7925    let mut c = col as isize;
7926    loop {
7927        let cur = &lines[r];
7928        let chars: Vec<char> = cur.chars().collect();
7929        // Clamp `c` to the line length: callers may seed `col` past
7930        // EOL on virtual-cursor lines (e.g., insert mode after `o`)
7931        // so direct indexing would panic on empty / short lines.
7932        if (c as usize) >= chars.len() {
7933            c = chars.len() as isize - 1;
7934        }
7935        while c >= 0 {
7936            let ch = chars[c as usize];
7937            if ch == close {
7938                depth += 1;
7939            } else if ch == open {
7940                if depth == 0 {
7941                    return Some((r, c as usize));
7942                }
7943                depth -= 1;
7944            }
7945            c -= 1;
7946        }
7947        if r == 0 {
7948            return None;
7949        }
7950        r -= 1;
7951        c = lines[r].chars().count() as isize - 1;
7952    }
7953}
7954
7955fn find_close_bracket(
7956    lines: &[String],
7957    row: usize,
7958    start_col: usize,
7959    open: char,
7960    close: char,
7961) -> Option<(usize, usize)> {
7962    let mut depth: i32 = 0;
7963    let mut r = row;
7964    let mut c = start_col;
7965    loop {
7966        let cur = &lines[r];
7967        let chars: Vec<char> = cur.chars().collect();
7968        while c < chars.len() {
7969            let ch = chars[c];
7970            if ch == open {
7971                depth += 1;
7972            } else if ch == close {
7973                if depth == 0 {
7974                    return Some((r, c));
7975                }
7976                depth -= 1;
7977            }
7978            c += 1;
7979        }
7980        if r + 1 >= lines.len() {
7981            return None;
7982        }
7983        r += 1;
7984        c = 0;
7985    }
7986}
7987
7988/// Forward scan from `(row, col)` for the next occurrence of `open`.
7989/// Multi-line. Used by bracket text objects to support targets.vim-style
7990/// "search forward when not currently inside a pair" behaviour.
7991fn find_next_open(lines: &[String], row: usize, col: usize, open: char) -> Option<(usize, usize)> {
7992    let mut r = row;
7993    let mut c = col;
7994    while r < lines.len() {
7995        let chars: Vec<char> = lines[r].chars().collect();
7996        while c < chars.len() {
7997            if chars[c] == open {
7998                return Some((r, c));
7999            }
8000            c += 1;
8001        }
8002        r += 1;
8003        c = 0;
8004    }
8005    None
8006}
8007
8008fn advance_pos(lines: &[String], pos: (usize, usize)) -> (usize, usize) {
8009    let (r, c) = pos;
8010    let line_len = lines[r].chars().count();
8011    if c < line_len {
8012        (r, c + 1)
8013    } else if r + 1 < lines.len() {
8014        (r + 1, 0)
8015    } else {
8016        pos
8017    }
8018}
8019
8020fn paragraph_text_object<H: crate::types::Host>(
8021    ed: &Editor<hjkl_buffer::Buffer, H>,
8022    inner: bool,
8023) -> Option<((usize, usize), (usize, usize))> {
8024    let (row, _) = ed.cursor();
8025    let rope = crate::types::Query::rope(&ed.buffer);
8026    let n_lines = rope.len_lines();
8027    if n_lines == 0 {
8028        return None;
8029    }
8030    // A paragraph is a run of non-blank lines.
8031    let is_blank = |r: usize| -> bool {
8032        if r >= n_lines {
8033            return true;
8034        }
8035        rope_line_to_str(&rope, r).trim().is_empty()
8036    };
8037    if is_blank(row) {
8038        return None;
8039    }
8040    let mut top = row;
8041    while top > 0 && !is_blank(top - 1) {
8042        top -= 1;
8043    }
8044    let mut bot = row;
8045    while bot + 1 < n_lines && !is_blank(bot + 1) {
8046        bot += 1;
8047    }
8048    // For `ap`, include one trailing blank line if present.
8049    if !inner && bot + 1 < n_lines && is_blank(bot + 1) {
8050        bot += 1;
8051    }
8052    let end_col = rope_line_to_str(&rope, bot).chars().count();
8053    Some(((top, 0), (bot, end_col)))
8054}
8055
8056// ─── Individual commands ───────────────────────────────────────────────────
8057
8058/// Read the text in a vim-shaped range without mutating. Used by
8059/// `Operator::Yank` so we can pipe the same range translation as
8060/// [`cut_vim_range`] but skip the delete + inverse extraction.
8061fn read_vim_range<H: crate::types::Host>(
8062    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8063    start: (usize, usize),
8064    end: (usize, usize),
8065    kind: RangeKind,
8066) -> String {
8067    let (top, bot) = order(start, end);
8068    ed.sync_buffer_content_from_textarea();
8069    let rope = crate::types::Query::rope(&ed.buffer);
8070    let n_lines = rope.len_lines();
8071    match kind {
8072        RangeKind::Linewise => {
8073            let lo = top.0;
8074            let hi = bot.0.min(n_lines.saturating_sub(1));
8075            let mut text = rope_row_range_str(&rope, lo, hi);
8076            text.push('\n');
8077            text
8078        }
8079        RangeKind::Inclusive | RangeKind::Exclusive => {
8080            let inclusive = matches!(kind, RangeKind::Inclusive);
8081            // Walk row-by-row collecting chars in `[top, end_exclusive)`.
8082            let mut out = String::new();
8083            for row in top.0..=bot.0 {
8084                if row >= n_lines {
8085                    break;
8086                }
8087                let line = rope_line_to_str(&rope, row);
8088                let lo = if row == top.0 { top.1 } else { 0 };
8089                let hi_unclamped = if row == bot.0 {
8090                    if inclusive { bot.1 + 1 } else { bot.1 }
8091                } else {
8092                    line.chars().count() + 1
8093                };
8094                let row_chars: Vec<char> = line.chars().collect();
8095                let hi = hi_unclamped.min(row_chars.len());
8096                if lo < hi {
8097                    out.push_str(&row_chars[lo..hi].iter().collect::<String>());
8098                }
8099                if row < bot.0 {
8100                    out.push('\n');
8101                }
8102            }
8103            out
8104        }
8105    }
8106}
8107
8108/// Cut a vim-shaped range through the Buffer edit funnel and return
8109/// the deleted text. Translates vim's `RangeKind`
8110/// (Linewise/Inclusive/Exclusive) into the buffer's
8111/// `hjkl_buffer::MotionKind` (Line/Char) and applies the right end-
8112/// position adjustment so inclusive motions actually include the bot
8113/// cell. Pushes the cut text into both `last_yank` and the textarea
8114/// yank buffer (still observed by `p`/`P` until the paste path is
8115/// ported), and updates `yank_linewise` for linewise cuts.
8116fn cut_vim_range<H: crate::types::Host>(
8117    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8118    start: (usize, usize),
8119    end: (usize, usize),
8120    kind: RangeKind,
8121) -> String {
8122    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
8123    let (top, bot) = order(start, end);
8124    ed.sync_buffer_content_from_textarea();
8125    let (buf_start, buf_end, buf_kind) = match kind {
8126        RangeKind::Linewise => (
8127            Position::new(top.0, 0),
8128            Position::new(bot.0, 0),
8129            BufKind::Line,
8130        ),
8131        RangeKind::Inclusive => {
8132            let line_chars = buf_line_chars(&ed.buffer, bot.0);
8133            // Advance one cell past `bot` so the buffer's exclusive
8134            // `cut_chars` actually drops the inclusive endpoint. Wrap
8135            // to the next row when bot already sits on the last char.
8136            let next = if bot.1 < line_chars {
8137                Position::new(bot.0, bot.1 + 1)
8138            } else if bot.0 + 1 < buf_row_count(&ed.buffer) {
8139                Position::new(bot.0 + 1, 0)
8140            } else {
8141                Position::new(bot.0, line_chars)
8142            };
8143            (Position::new(top.0, top.1), next, BufKind::Char)
8144        }
8145        RangeKind::Exclusive => (
8146            Position::new(top.0, top.1),
8147            Position::new(bot.0, bot.1),
8148            BufKind::Char,
8149        ),
8150    };
8151    let inverse = ed.mutate_edit(Edit::DeleteRange {
8152        start: buf_start,
8153        end: buf_end,
8154        kind: buf_kind,
8155    });
8156    let text = match inverse {
8157        Edit::InsertStr { text, .. } => text,
8158        _ => String::new(),
8159    };
8160    if !text.is_empty() {
8161        ed.record_yank_to_host(text.clone());
8162        ed.record_delete(text.clone(), matches!(kind, RangeKind::Linewise));
8163    }
8164    ed.push_buffer_cursor_to_textarea();
8165    text
8166}
8167
8168/// `D` / `C` — delete from cursor to end of line through the edit
8169/// funnel. Mirrors the deleted text into both `ed.last_yank` and the
8170/// textarea's yank buffer (still observed by `p`/`P` until the paste
8171/// path is ported). Cursor lands at the deletion start so the caller
8172/// can decide whether to step it left (`D`) or open insert mode (`C`).
8173fn delete_to_eol<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
8174    use hjkl_buffer::{Edit, MotionKind, Position};
8175    ed.sync_buffer_content_from_textarea();
8176    let cursor = buf_cursor_pos(&ed.buffer);
8177    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
8178    if cursor.col >= line_chars {
8179        return;
8180    }
8181    let inverse = ed.mutate_edit(Edit::DeleteRange {
8182        start: cursor,
8183        end: Position::new(cursor.row, line_chars),
8184        kind: MotionKind::Char,
8185    });
8186    if let Edit::InsertStr { text, .. } = inverse
8187        && !text.is_empty()
8188    {
8189        ed.record_yank_to_host(text.clone());
8190        ed.vim.yank_linewise = false;
8191        ed.set_yank(text);
8192    }
8193    buf_set_cursor_pos(&mut ed.buffer, cursor);
8194    ed.push_buffer_cursor_to_textarea();
8195}
8196
8197fn do_char_delete<H: crate::types::Host>(
8198    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8199    forward: bool,
8200    count: usize,
8201) {
8202    use hjkl_buffer::{Edit, MotionKind, Position};
8203    ed.push_undo();
8204    ed.sync_buffer_content_from_textarea();
8205    // Collect deleted chars so we can write them to the unnamed register
8206    // (vim's `x`/`X` populate `"` so that `xp` round-trips the char).
8207    let mut deleted = String::new();
8208    for _ in 0..count {
8209        let cursor = buf_cursor_pos(&ed.buffer);
8210        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
8211        if forward {
8212            // `x` — delete the char under the cursor. Vim no-ops on
8213            // an empty line; the buffer would drop a row otherwise.
8214            if cursor.col >= line_chars {
8215                continue;
8216            }
8217            let inverse = ed.mutate_edit(Edit::DeleteRange {
8218                start: cursor,
8219                end: Position::new(cursor.row, cursor.col + 1),
8220                kind: MotionKind::Char,
8221            });
8222            if let Edit::InsertStr { text, .. } = inverse {
8223                deleted.push_str(&text);
8224            }
8225        } else {
8226            // `X` — delete the char before the cursor.
8227            if cursor.col == 0 {
8228                continue;
8229            }
8230            let inverse = ed.mutate_edit(Edit::DeleteRange {
8231                start: Position::new(cursor.row, cursor.col - 1),
8232                end: cursor,
8233                kind: MotionKind::Char,
8234            });
8235            if let Edit::InsertStr { text, .. } = inverse {
8236                // X deletes backwards; prepend so the register text
8237                // matches reading order (first deleted char first).
8238                deleted = text + &deleted;
8239            }
8240        }
8241    }
8242    if !deleted.is_empty() {
8243        ed.record_yank_to_host(deleted.clone());
8244        ed.record_delete(deleted, false);
8245    }
8246    ed.push_buffer_cursor_to_textarea();
8247}
8248
8249/// Vim `Ctrl-a` / `Ctrl-x` — find the next number at or after the cursor on the
8250/// current line, add `delta`, leave the cursor on the last digit of the result.
8251/// Recognises `0x`/`0X` hex literals (incremented in hex, width preserved) as
8252/// well as signed decimals. No-op if the line has no number to the right.
8253pub(crate) fn adjust_number<H: crate::types::Host>(
8254    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8255    delta: i64,
8256) -> bool {
8257    use hjkl_buffer::{Edit, MotionKind, Position};
8258    ed.sync_buffer_content_from_textarea();
8259    let cursor = buf_cursor_pos(&ed.buffer);
8260    let row = cursor.row;
8261    let chars: Vec<char> = match buf_line(&ed.buffer, row) {
8262        Some(l) => l.chars().collect(),
8263        None => return false,
8264    };
8265    let len = chars.len();
8266
8267    // Scan from the cursor for the start of the leftmost number — a `0x`/`0X`
8268    // hex literal takes priority over a bare decimal at the same position.
8269    let is_hex_prefix = |i: usize| {
8270        chars[i] == '0'
8271            && i + 1 < len
8272            && matches!(chars[i + 1], 'x' | 'X')
8273            && chars.get(i + 2).is_some_and(|c| c.is_ascii_hexdigit())
8274    };
8275    let mut i = cursor.col;
8276    let mut hex = false;
8277    loop {
8278        if i >= len {
8279            return false;
8280        }
8281        if is_hex_prefix(i) {
8282            hex = true;
8283            break;
8284        }
8285        if chars[i].is_ascii_digit() {
8286            break;
8287        }
8288        i += 1;
8289    }
8290
8291    let (span_start, span_end, new_s) = if hex {
8292        // `0x` + hex digits. Increment the value, preserve the digit width.
8293        let digits_start = i + 2;
8294        let mut digits_end = digits_start;
8295        while digits_end < len && chars[digits_end].is_ascii_hexdigit() {
8296            digits_end += 1;
8297        }
8298        let hexs: String = chars[digits_start..digits_end].iter().collect();
8299        let Ok(n) = u64::from_str_radix(&hexs, 16) else {
8300            return false;
8301        };
8302        let new_val = (n as i128 + delta as i128).max(0) as u64;
8303        let width = digits_end - digits_start;
8304        let prefix: String = chars[i..digits_start].iter().collect();
8305        (i, digits_end, format!("{prefix}{new_val:0width$x}"))
8306    } else {
8307        // Signed decimal.
8308        let digit_start = i;
8309        let span_start = if digit_start > 0 && chars[digit_start - 1] == '-' {
8310            digit_start - 1
8311        } else {
8312            digit_start
8313        };
8314        let mut span_end = digit_start;
8315        while span_end < len && chars[span_end].is_ascii_digit() {
8316            span_end += 1;
8317        }
8318        let s: String = chars[span_start..span_end].iter().collect();
8319        let Ok(n) = s.parse::<i64>() else {
8320            return false;
8321        };
8322        (span_start, span_end, n.saturating_add(delta).to_string())
8323    };
8324
8325    ed.push_undo();
8326    let span_start_pos = Position::new(row, span_start);
8327    let span_end_pos = Position::new(row, span_end);
8328    ed.mutate_edit(Edit::DeleteRange {
8329        start: span_start_pos,
8330        end: span_end_pos,
8331        kind: MotionKind::Char,
8332    });
8333    ed.mutate_edit(Edit::InsertStr {
8334        at: span_start_pos,
8335        text: new_s.clone(),
8336    });
8337    let new_len = new_s.chars().count();
8338    buf_set_cursor_rc(&mut ed.buffer, row, span_start + new_len.saturating_sub(1));
8339    ed.push_buffer_cursor_to_textarea();
8340    true
8341}
8342
8343pub(crate) fn replace_char<H: crate::types::Host>(
8344    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8345    ch: char,
8346    count: usize,
8347) {
8348    use hjkl_buffer::{Edit, MotionKind, Position};
8349    ed.push_undo();
8350    ed.sync_buffer_content_from_textarea();
8351    for _ in 0..count {
8352        let cursor = buf_cursor_pos(&ed.buffer);
8353        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
8354        if cursor.col >= line_chars {
8355            break;
8356        }
8357        ed.mutate_edit(Edit::DeleteRange {
8358            start: cursor,
8359            end: Position::new(cursor.row, cursor.col + 1),
8360            kind: MotionKind::Char,
8361        });
8362        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
8363    }
8364    // Vim leaves the cursor on the last replaced char.
8365    crate::motions::move_left(&mut ed.buffer, 1);
8366    ed.push_buffer_cursor_to_textarea();
8367}
8368
8369fn toggle_case_at_cursor<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
8370    use hjkl_buffer::{Edit, MotionKind, Position};
8371    ed.sync_buffer_content_from_textarea();
8372    let cursor = buf_cursor_pos(&ed.buffer);
8373    let Some(c) = buf_line(&ed.buffer, cursor.row).and_then(|l| l.chars().nth(cursor.col)) else {
8374        return;
8375    };
8376    let toggled = if c.is_uppercase() {
8377        c.to_lowercase().next().unwrap_or(c)
8378    } else {
8379        c.to_uppercase().next().unwrap_or(c)
8380    };
8381    ed.mutate_edit(Edit::DeleteRange {
8382        start: cursor,
8383        end: Position::new(cursor.row, cursor.col + 1),
8384        kind: MotionKind::Char,
8385    });
8386    ed.mutate_edit(Edit::InsertChar {
8387        at: cursor,
8388        ch: toggled,
8389    });
8390}
8391
8392fn join_line<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
8393    use hjkl_buffer::{Edit, Position};
8394    ed.sync_buffer_content_from_textarea();
8395    let row = buf_cursor_pos(&ed.buffer).row;
8396    if row + 1 >= buf_row_count(&ed.buffer) {
8397        return;
8398    }
8399    let cur_line = buf_line(&ed.buffer, row).unwrap_or_default();
8400    let next_raw = buf_line(&ed.buffer, row + 1).unwrap_or_default();
8401    let next_trimmed = next_raw.trim_start();
8402    let cur_chars = cur_line.chars().count();
8403    let next_chars = next_raw.chars().count();
8404    // `J` inserts a single space iff both sides are non-empty after
8405    // stripping the next line's leading whitespace.
8406    let separator = if !cur_line.is_empty() && !next_trimmed.is_empty() {
8407        " "
8408    } else {
8409        ""
8410    };
8411    let joined = format!("{cur_line}{separator}{next_trimmed}");
8412    ed.mutate_edit(Edit::Replace {
8413        start: Position::new(row, 0),
8414        end: Position::new(row + 1, next_chars),
8415        with: joined,
8416    });
8417    // Vim parks the cursor on the inserted space — or at the join
8418    // point when no space went in (which is the same column either
8419    // way, since the space sits exactly at `cur_chars`).
8420    buf_set_cursor_rc(&mut ed.buffer, row, cur_chars);
8421    ed.push_buffer_cursor_to_textarea();
8422}
8423
8424/// `gJ` — join the next line onto the current one without inserting a
8425/// separating space or stripping leading whitespace.
8426fn join_line_raw<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
8427    use hjkl_buffer::Edit;
8428    ed.sync_buffer_content_from_textarea();
8429    let row = buf_cursor_pos(&ed.buffer).row;
8430    if row + 1 >= buf_row_count(&ed.buffer) {
8431        return;
8432    }
8433    let join_col = buf_line_chars(&ed.buffer, row);
8434    ed.mutate_edit(Edit::JoinLines {
8435        row,
8436        count: 1,
8437        with_space: false,
8438    });
8439    // Vim leaves the cursor at the join point (end of original line).
8440    buf_set_cursor_rc(&mut ed.buffer, row, join_col);
8441    ed.push_buffer_cursor_to_textarea();
8442}
8443
8444/// Visual-mode `J` (`with_space = true`) / `gJ` (`with_space = false`) — join
8445/// every line spanned by the selection into one. A single-line selection joins
8446/// the current line with the one below (matching normal-mode `J`).
8447pub(crate) fn visual_join<H: crate::types::Host>(
8448    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8449    with_space: bool,
8450) {
8451    let cursor_row = buf_cursor_pos(&ed.buffer).row;
8452    let (top, bot) = match ed.vim.mode {
8453        Mode::VisualLine => (
8454            cursor_row.min(ed.vim.visual_line_anchor),
8455            cursor_row.max(ed.vim.visual_line_anchor),
8456        ),
8457        Mode::VisualBlock => {
8458            let a = ed.vim.block_anchor.0;
8459            (a.min(cursor_row), a.max(cursor_row))
8460        }
8461        Mode::Visual => {
8462            let a = ed.vim.visual_anchor.0;
8463            (a.min(cursor_row), a.max(cursor_row))
8464        }
8465        _ => return,
8466    };
8467    // N selected lines → N-1 joins; a single line still does one join (with the
8468    // line below) like normal-mode `J`.
8469    let joins = (bot - top).max(1);
8470    ed.push_undo();
8471    buf_set_cursor_rc(&mut ed.buffer, top, 0);
8472    ed.push_buffer_cursor_to_textarea();
8473    for _ in 0..joins {
8474        if with_space {
8475            join_line(ed);
8476        } else {
8477            join_line_raw(ed);
8478        }
8479    }
8480    ed.vim.mode = Mode::Normal;
8481    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
8482}
8483
8484/// `[count]%` — go to the line at `count` percent of the file (vim: line
8485/// `(count * line_count + 99) / 100`), cursor on the first non-blank.
8486pub(crate) fn goto_percent<H: crate::types::Host>(
8487    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8488    count: usize,
8489) {
8490    let rows = buf_row_count(&ed.buffer);
8491    if rows == 0 {
8492        return;
8493    }
8494    // Exclude the phantom trailing empty line (a file ending in `\n` is N lines
8495    // in vim, not N+1) so the percentage matches nvim.
8496    let total = if rows >= 2
8497        && buf_line(&ed.buffer, rows - 1)
8498            .map(|s| s.is_empty())
8499            .unwrap_or(false)
8500    {
8501        rows - 1
8502    } else {
8503        rows
8504    };
8505    // 1-based target line, clamped to the buffer (vim: ceil(count*lines/100)).
8506    let line = (count * total).div_ceil(100).clamp(1, total);
8507    let pre = ed.cursor();
8508    ed.jump_cursor(line - 1, 0);
8509    move_first_non_whitespace(ed);
8510    ed.sticky_col = Some(ed.cursor().1);
8511    if ed.cursor() != pre {
8512        ed.push_jump(pre);
8513    }
8514}
8515
8516/// Indent width of a leading-whitespace prefix, counting a `\t` as advancing
8517/// to the next `tabstop` boundary and a space as one column.
8518fn indent_width(s: &str, tabstop: usize) -> usize {
8519    let ts = tabstop.max(1);
8520    let mut w = 0usize;
8521    for c in s.chars() {
8522        match c {
8523            ' ' => w += 1,
8524            '\t' => w += ts - (w % ts),
8525            _ => break,
8526        }
8527    }
8528    w
8529}
8530
8531/// Build a leading-whitespace string of `width` columns honoring `expandtab`
8532/// (spaces) vs `noexpandtab` (tabs for full `tabstop` runs, spaces remainder).
8533fn build_indent(width: usize, settings: &crate::editor::Settings) -> String {
8534    if settings.expandtab {
8535        return " ".repeat(width);
8536    }
8537    let ts = settings.tabstop.max(1);
8538    let tabs = width / ts;
8539    let spaces = width % ts;
8540    format!("{}{}", "\t".repeat(tabs), " ".repeat(spaces))
8541}
8542
8543/// `]p` / `[p` reindent: shift every line of `text` so the FIRST line's indent
8544/// matches `target_width` columns; later lines keep their relative offset.
8545fn reindent_block(text: &str, target_width: usize, settings: &crate::editor::Settings) -> String {
8546    let ts = settings.tabstop.max(1);
8547    let lines: Vec<&str> = text.split('\n').collect();
8548    let first_width = lines.first().map(|l| indent_width(l, ts)).unwrap_or(0);
8549    let delta = target_width as isize - first_width as isize;
8550    lines
8551        .iter()
8552        .map(|line| {
8553            let trimmed = line.trim_start_matches([' ', '\t']);
8554            if trimmed.is_empty() {
8555                // Preserve blank lines as truly empty (vim does not indent them).
8556                return String::new();
8557            }
8558            let old_w = indent_width(line, ts) as isize;
8559            let new_w = (old_w + delta).max(0) as usize;
8560            format!("{}{}", build_indent(new_w, settings), trimmed)
8561        })
8562        .collect::<Vec<_>>()
8563        .join("\n")
8564}
8565
8566fn do_paste<H: crate::types::Host>(
8567    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8568    before: bool,
8569    count: usize,
8570    cursor_after: bool,
8571    reindent: bool,
8572) {
8573    use hjkl_buffer::{Edit, Position};
8574    ed.push_undo();
8575    // Resolve the source register: `"reg` prefix (consumed) or the
8576    // unnamed register otherwise. Read text + linewise from the
8577    // selected slot rather than the global `vim.yank_linewise` so
8578    // pasting from `"0` after a delete still uses the yank's layout.
8579    let selector = ed.vim.pending_register.take();
8580    let (yank, linewise) = match selector.and_then(|c| ed.registers().read(c)) {
8581        Some(slot) => (slot.text.clone(), slot.linewise),
8582        // Read both fields from the unnamed slot rather than mixing the
8583        // slot's text with `vim.yank_linewise`. The cached vim flag is
8584        // per-editor, so a register imported from another editor (e.g.
8585        // cross-buffer yank/paste) carried the wrong linewise without
8586        // this — pasting a linewise yank inserted at the char cursor.
8587        None => {
8588            let s = &ed.registers().unnamed;
8589            (s.text.clone(), s.linewise)
8590        }
8591    };
8592    // Vim `:h '[` / `:h ']`: after paste `[` = first inserted char of
8593    // the final paste, `]` = last inserted char of the final paste.
8594    // We track (lo, hi) across iterations; the last value wins.
8595    let mut paste_mark: Option<((usize, usize), (usize, usize))> = None;
8596    // Capture the cursor row before any paste iterations. Vim's
8597    // linewise `[count]p` lands the cursor on the FIRST pasted line
8598    // (original_row + 1), not on the last iteration's paste row.
8599    // Without this snapshot the per-iteration cursor advancement leaves
8600    // the cursor at `original_row + count` instead.
8601    let original_row_for_linewise_after = if linewise && !before {
8602        // Fold-aware: `p` on a closed fold pastes after the fold, so the first
8603        // pasted line is `fold_end + 1`, not `cursor_row + 1`.
8604        let r = buf_cursor_pos(&ed.buffer).row;
8605        let (_, fold_end) = expand_linewise_over_closed_folds(&ed.buffer, r, r);
8606        Some(fold_end)
8607    } else {
8608        None
8609    };
8610    for _ in 0..count {
8611        ed.sync_buffer_content_from_textarea();
8612        let yank = yank.clone();
8613        if yank.is_empty() {
8614            continue;
8615        }
8616        if linewise {
8617            // Linewise paste: insert payload as fresh row(s) above
8618            // (`P`) or below (`p`) the cursor's row. Cursor lands on
8619            // the first non-blank of the first pasted line.
8620            let mut text = yank.trim_matches('\n').to_string();
8621            let row = buf_cursor_pos(&ed.buffer).row;
8622            // `]p` / `[p` — reindent the pasted block to the current line.
8623            if reindent {
8624                let cur_line = buf_line(&ed.buffer, row).unwrap_or_default();
8625                let target_w = indent_width(&cur_line, ed.settings.tabstop.max(1));
8626                text = reindent_block(&text, target_w, &ed.settings);
8627            }
8628            // Fold-aware: linewise paste lands relative to the whole CLOSED
8629            // fold, not just the cursor line — `p` after the fold's last row,
8630            // `P` before its first row (vim behaviour). No fold → unchanged.
8631            let (fold_start, fold_end) = expand_linewise_over_closed_folds(&ed.buffer, row, row);
8632            let target_row = if before {
8633                ed.mutate_edit(Edit::InsertStr {
8634                    at: Position::new(fold_start, 0),
8635                    text: format!("{text}\n"),
8636                });
8637                fold_start
8638            } else {
8639                let line_chars = buf_line_chars(&ed.buffer, fold_end);
8640                ed.mutate_edit(Edit::InsertStr {
8641                    at: Position::new(fold_end, line_chars),
8642                    text: format!("\n{text}"),
8643                });
8644                fold_end + 1
8645            };
8646            buf_set_cursor_rc(&mut ed.buffer, target_row, 0);
8647            crate::motions::move_first_non_blank(&mut ed.buffer);
8648            ed.push_buffer_cursor_to_textarea();
8649            // Linewise: `[` = (target_row, 0), `]` = (bot_row, last_col).
8650            let payload_lines = text.lines().count().max(1);
8651            let bot_row = target_row + payload_lines - 1;
8652            let bot_last_col = buf_line_chars(&ed.buffer, bot_row).saturating_sub(1);
8653            paste_mark = Some(((target_row, 0), (bot_row, bot_last_col)));
8654        } else {
8655            // Charwise paste. `P` inserts at cursor (shifting cell
8656            // right); `p` inserts after cursor (advance one cell
8657            // first, clamped to the end of the line).
8658            let cursor = buf_cursor_pos(&ed.buffer);
8659            let at = if before {
8660                cursor
8661            } else {
8662                let line_chars = buf_line_chars(&ed.buffer, cursor.row);
8663                Position::new(cursor.row, (cursor.col + 1).min(line_chars))
8664            };
8665            ed.mutate_edit(Edit::InsertStr {
8666                at,
8667                text: yank.clone(),
8668            });
8669            // Vim parks the cursor on the last char of the pasted text
8670            // (do_insert_str leaves it one past the end). `gp` instead
8671            // leaves the cursor just AFTER the pasted text, so skip the
8672            // step-back there.
8673            if !cursor_after && ed.cursor().1 > 0 {
8674                crate::motions::move_left(&mut ed.buffer, 1);
8675                ed.push_buffer_cursor_to_textarea();
8676            }
8677            // Charwise: `[` = insert start, `]` = last pasted char.
8678            let lo = (at.row, at.col);
8679            let hi = if cursor_after {
8680                let c = ed.cursor();
8681                (c.0, c.1.saturating_sub(1))
8682            } else {
8683                ed.cursor()
8684            };
8685            paste_mark = Some((lo, hi));
8686        }
8687    }
8688    if let Some((lo, hi)) = paste_mark {
8689        ed.set_mark('[', lo);
8690        ed.set_mark(']', hi);
8691    }
8692    // `gp` / `gP` linewise: cursor lands on the line just AFTER the pasted
8693    // block (the `]` mark's row + 1), at column 0, clamped to the last row.
8694    if cursor_after && linewise {
8695        if let Some((_, (bot_row, _))) = paste_mark {
8696            let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
8697            let target = (bot_row + 1).min(last_row);
8698            buf_set_cursor_rc(&mut ed.buffer, target, 0);
8699            ed.push_buffer_cursor_to_textarea();
8700        }
8701    } else if let Some(orig_row) = original_row_for_linewise_after {
8702        // Linewise `p` (after) with count: cursor lands on the FIRST pasted
8703        // line (original_row + 1) — vim parity. The per-iteration loop
8704        // moves cursor to each paste's target_row, so without this reset
8705        // `5p` would land at original_row + 5 instead of original_row + 1.
8706        let first_target = orig_row.saturating_add(1);
8707        buf_set_cursor_rc(&mut ed.buffer, first_target, 0);
8708        crate::motions::move_first_non_blank(&mut ed.buffer);
8709        ed.push_buffer_cursor_to_textarea();
8710    }
8711    // Any paste re-anchors the sticky column to the new cursor position.
8712    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
8713}
8714
8715/// Visual-mode `p` / `P` — replace the active selection with the register.
8716/// With `p` the deleted selection lands in the unnamed register (vim's swap);
8717/// with `P` (`before = true`) the source register is preserved so it can be
8718/// pasted over multiple selections in turn.
8719pub(crate) fn visual_paste<H: crate::types::Host>(
8720    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8721    before: bool,
8722) {
8723    use hjkl_buffer::{Edit, Position};
8724    ed.sync_buffer_content_from_textarea();
8725
8726    // Resolve the source register (selector or unnamed) BEFORE the delete
8727    // overwrites the unnamed register with the cut selection.
8728    let selector = ed.vim.pending_register.take();
8729    let (reg_text, reg_linewise) = match selector.and_then(|c| ed.registers().read(c)) {
8730        Some(slot) => (slot.text.clone(), slot.linewise),
8731        None => {
8732            let s = &ed.registers().unnamed;
8733            (s.text.clone(), s.linewise)
8734        }
8735    };
8736    // For `P`, snapshot the unnamed register so we can restore it afterwards.
8737    let saved_unnamed = before.then(|| ed.registers().unnamed.clone());
8738
8739    let mode = ed.vim.mode;
8740    ed.push_undo();
8741
8742    match mode {
8743        Mode::VisualLine => {
8744            let cursor_row = buf_cursor_pos(&ed.buffer).row;
8745            let top = cursor_row.min(ed.vim.visual_line_anchor);
8746            let bot = cursor_row.max(ed.vim.visual_line_anchor);
8747            // Delete the selected lines into the unnamed register.
8748            cut_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
8749            // Insert the register as fresh line(s) where the selection was.
8750            let text = reg_text.trim_matches('\n').to_string();
8751            let line_count = buf_row_count(&ed.buffer);
8752            if top >= line_count {
8753                // Selection reached the end of the buffer: append below the
8754                // (new) last line.
8755                let last = line_count.saturating_sub(1);
8756                let lc = buf_line_chars(&ed.buffer, last);
8757                ed.mutate_edit(Edit::InsertStr {
8758                    at: Position::new(last, lc),
8759                    text: format!("\n{text}"),
8760                });
8761                buf_set_cursor_rc(&mut ed.buffer, last + 1, 0);
8762            } else {
8763                ed.mutate_edit(Edit::InsertStr {
8764                    at: Position::new(top, 0),
8765                    text: format!("{text}\n"),
8766                });
8767                buf_set_cursor_rc(&mut ed.buffer, top, 0);
8768            }
8769            crate::motions::move_first_non_blank(&mut ed.buffer);
8770            ed.push_buffer_cursor_to_textarea();
8771        }
8772        Mode::Visual | Mode::VisualBlock => {
8773            let anchor = if mode == Mode::VisualBlock {
8774                ed.vim.block_anchor
8775            } else {
8776                ed.vim.visual_anchor
8777            };
8778            let cursor = ed.cursor();
8779            let (top, bot) = order(anchor, cursor);
8780            // Delete the selection into the unnamed register.
8781            cut_vim_range(ed, top, bot, RangeKind::Inclusive);
8782            // Insert the register text where the selection started.
8783            if reg_linewise {
8784                // Linewise register into a charwise hole: open a line below.
8785                let text = reg_text.trim_matches('\n').to_string();
8786                let lc = buf_line_chars(&ed.buffer, top.0);
8787                ed.mutate_edit(Edit::InsertStr {
8788                    at: Position::new(top.0, lc),
8789                    text: format!("\n{text}"),
8790                });
8791                buf_set_cursor_rc(&mut ed.buffer, top.0 + 1, 0);
8792                crate::motions::move_first_non_blank(&mut ed.buffer);
8793            } else {
8794                ed.mutate_edit(Edit::InsertStr {
8795                    at: Position::new(top.0, top.1),
8796                    text: reg_text.clone(),
8797                });
8798                // Park the cursor on the last char of the inserted text.
8799                let inserted_len = reg_text.chars().count();
8800                let last_col = top.1 + inserted_len.saturating_sub(1);
8801                buf_set_cursor_rc(&mut ed.buffer, top.0, last_col);
8802            }
8803            ed.push_buffer_cursor_to_textarea();
8804        }
8805        _ => {}
8806    }
8807
8808    // `P` preserves the source register; restore the snapshot.
8809    if let Some(slot) = saved_unnamed {
8810        ed.registers_mut().unnamed = slot;
8811    }
8812    ed.vim.mode = Mode::Normal;
8813    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
8814}
8815
8816/// Visual-mode `<C-a>` / `<C-x>` and `g<C-a>` / `g<C-x>`. Adds `delta` to the
8817/// first number on each selected line. When `sequential` is true the increment
8818/// grows by `delta` for each successive number found (vim's `g<C-a>`): the
8819/// first gets `delta`, the second `2*delta`, and so on.
8820pub(crate) fn adjust_number_visual<H: crate::types::Host>(
8821    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8822    delta: i64,
8823    sequential: bool,
8824) {
8825    use hjkl_buffer::{Edit, MotionKind, Position};
8826    ed.sync_buffer_content_from_textarea();
8827    let mode = ed.vim.mode;
8828    let cursor = buf_cursor_pos(&ed.buffer);
8829
8830    // Resolve the row range + the per-row start column to scan from.
8831    let (top, bot, mut scan_col_first, block_left) = match mode {
8832        Mode::VisualLine => {
8833            let t = cursor.row.min(ed.vim.visual_line_anchor);
8834            let b = cursor.row.max(ed.vim.visual_line_anchor);
8835            (t, b, 0usize, None)
8836        }
8837        Mode::Visual => {
8838            let (a, c) = order(ed.vim.visual_anchor, (cursor.row, cursor.col));
8839            (a.0, c.0, a.1, None)
8840        }
8841        Mode::VisualBlock => {
8842            let (a, c) = order(ed.vim.block_anchor, (cursor.row, cursor.col));
8843            let left = a.1.min(c.1);
8844            (a.0, c.0, left, Some(left))
8845        }
8846        _ => return,
8847    };
8848
8849    ed.push_undo();
8850    let mut found_count: i64 = 0;
8851    for row in top..=bot {
8852        let start_col = match block_left {
8853            Some(left) => left,
8854            None => {
8855                // First row of a charwise selection starts at the anchor/cursor
8856                // column; subsequent rows start at column 0.
8857                let c = if row == top { scan_col_first } else { 0 };
8858                scan_col_first = 0;
8859                c
8860            }
8861        };
8862        let chars: Vec<char> = match buf_line(&ed.buffer, row) {
8863            Some(l) => l.chars().collect(),
8864            None => continue,
8865        };
8866        let Some(digit_start) =
8867            (start_col.min(chars.len())..chars.len()).find(|&i| chars[i].is_ascii_digit())
8868        else {
8869            continue;
8870        };
8871        let span_start = if digit_start > 0 && chars[digit_start - 1] == '-' {
8872            digit_start - 1
8873        } else {
8874            digit_start
8875        };
8876        let mut span_end = digit_start;
8877        while span_end < chars.len() && chars[span_end].is_ascii_digit() {
8878            span_end += 1;
8879        }
8880        let s: String = chars[span_start..span_end].iter().collect();
8881        let Ok(n) = s.parse::<i64>() else {
8882            continue;
8883        };
8884        found_count += 1;
8885        let this_delta = if sequential {
8886            delta.saturating_mul(found_count)
8887        } else {
8888            delta
8889        };
8890        let new_s = n.saturating_add(this_delta).to_string();
8891        let span_start_pos = Position::new(row, span_start);
8892        let span_end_pos = Position::new(row, span_end);
8893        ed.mutate_edit(Edit::DeleteRange {
8894            start: span_start_pos,
8895            end: span_end_pos,
8896            kind: MotionKind::Char,
8897        });
8898        ed.mutate_edit(Edit::InsertStr {
8899            at: span_start_pos,
8900            text: new_s,
8901        });
8902    }
8903    // Vim leaves the cursor at the start of the selection.
8904    buf_set_cursor_rc(&mut ed.buffer, top, block_left.unwrap_or(0));
8905    ed.push_buffer_cursor_to_textarea();
8906    ed.vim.mode = Mode::Normal;
8907    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
8908}
8909
8910pub(crate) fn do_undo<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
8911    if let Some(entry) = ed.undo_stack.pop() {
8912        let (cur_rope, cur_cursor) = ed.snapshot();
8913        ed.redo_stack.push(crate::editor::UndoEntry {
8914            rope: cur_rope,
8915            cursor: cur_cursor,
8916            timestamp: entry.timestamp,
8917        });
8918        ed.restore_rope(entry.rope, entry.cursor);
8919    }
8920    ed.vim.mode = Mode::Normal;
8921    // The restored cursor came from a snapshot taken in insert mode
8922    // (before the insert started) and may be past the last valid
8923    // normal-mode column. Clamp it now, same as Esc-from-insert does.
8924    clamp_cursor_to_normal_mode(ed);
8925}
8926
8927pub(crate) fn do_redo<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
8928    if let Some(entry) = ed.redo_stack.pop() {
8929        let (cur_rope, cur_cursor) = ed.snapshot();
8930        let before = cur_rope.clone();
8931        ed.undo_stack.push(crate::editor::UndoEntry {
8932            rope: cur_rope,
8933            cursor: cur_cursor,
8934            timestamp: entry.timestamp,
8935        });
8936        ed.cap_undo();
8937        ed.restore_rope(entry.rope, entry.cursor);
8938        // vim parks the cursor at the START of the reapplied change, not the
8939        // end-of-insert position stored in the redo snapshot. Recompute it from
8940        // the first character that differs between the pre- and post-redo text.
8941        let after = crate::types::Query::rope(&ed.buffer);
8942        if let Some((row, col)) = first_diff_pos(&before, &after) {
8943            buf_set_cursor_rc(&mut ed.buffer, row, col);
8944            ed.push_buffer_cursor_to_textarea();
8945        }
8946    }
8947    ed.vim.mode = Mode::Normal;
8948    clamp_cursor_to_normal_mode(ed);
8949}
8950
8951/// First `(row, col)` where two ropes differ, or `None` if identical. Used to
8952/// place the cursor at the start of a redone change (vim parity).
8953fn first_diff_pos(a: &ropey::Rope, b: &ropey::Rope) -> Option<(usize, usize)> {
8954    let rows = a.len_lines().max(b.len_lines());
8955    for r in 0..rows {
8956        let la = if r < a.len_lines() {
8957            hjkl_buffer::rope_line_str(a, r)
8958        } else {
8959            String::new()
8960        };
8961        let lb = if r < b.len_lines() {
8962            hjkl_buffer::rope_line_str(b, r)
8963        } else {
8964            String::new()
8965        };
8966        if la != lb {
8967            let col = la
8968                .chars()
8969                .zip(lb.chars())
8970                .take_while(|(x, y)| x == y)
8971                .count();
8972            return Some((r, col));
8973        }
8974    }
8975    None
8976}
8977
8978// ─── Dot repeat ────────────────────────────────────────────────────────────
8979
8980/// Replay-side helper: insert `text` at the cursor through the
8981/// edit funnel, then leave insert mode (the original change ended
8982/// with Esc, so the dot-repeat must end the same way — including
8983/// the cursor step-back vim does on Esc-from-insert).
8984fn replay_insert_and_finish<H: crate::types::Host>(
8985    ed: &mut Editor<hjkl_buffer::Buffer, H>,
8986    text: &str,
8987) {
8988    use hjkl_buffer::{Edit, Position};
8989    let cursor = ed.cursor();
8990    ed.mutate_edit(Edit::InsertStr {
8991        at: Position::new(cursor.0, cursor.1),
8992        text: text.to_string(),
8993    });
8994    if ed.vim.insert_session.take().is_some() {
8995        if ed.cursor().1 > 0 {
8996            crate::motions::move_left(&mut ed.buffer, 1);
8997            ed.push_buffer_cursor_to_textarea();
8998        }
8999        ed.vim.mode = Mode::Normal;
9000    }
9001}
9002
9003pub(crate) fn replay_last_change<H: crate::types::Host>(
9004    ed: &mut Editor<hjkl_buffer::Buffer, H>,
9005    outer_count: usize,
9006) {
9007    let Some(change) = ed.vim.last_change.clone() else {
9008        return;
9009    };
9010    ed.vim.replaying = true;
9011    let scale = if outer_count > 0 { outer_count } else { 1 };
9012    match change {
9013        LastChange::OpMotion {
9014            op,
9015            motion,
9016            count,
9017            inserted,
9018        } => {
9019            let total = count.max(1) * scale;
9020            apply_op_with_motion(ed, op, &motion, total);
9021            if let Some(text) = inserted {
9022                replay_insert_and_finish(ed, &text);
9023            }
9024        }
9025        LastChange::OpTextObj {
9026            op,
9027            obj,
9028            inner,
9029            inserted,
9030        } => {
9031            // Dot-repeat replays the text object at count 1 (the original
9032            // count is not retained in `LastChange::OpTextObj`).
9033            apply_op_with_text_object(ed, op, obj, inner, 1);
9034            if let Some(text) = inserted {
9035                replay_insert_and_finish(ed, &text);
9036            }
9037        }
9038        LastChange::LineOp {
9039            op,
9040            count,
9041            inserted,
9042        } => {
9043            let total = count.max(1) * scale;
9044            execute_line_op(ed, op, total);
9045            if let Some(text) = inserted {
9046                replay_insert_and_finish(ed, &text);
9047            }
9048        }
9049        LastChange::CharDel { forward, count } => {
9050            do_char_delete(ed, forward, count * scale);
9051        }
9052        LastChange::ReplaceChar { ch, count } => {
9053            replace_char(ed, ch, count * scale);
9054        }
9055        LastChange::ToggleCase { count } => {
9056            for _ in 0..count * scale {
9057                ed.push_undo();
9058                toggle_case_at_cursor(ed);
9059            }
9060        }
9061        LastChange::JoinLine { count } => {
9062            for _ in 0..count * scale {
9063                ed.push_undo();
9064                join_line(ed);
9065            }
9066        }
9067        LastChange::Paste {
9068            before,
9069            count,
9070            cursor_after,
9071            reindent,
9072        } => {
9073            do_paste(ed, before, count * scale, cursor_after, reindent);
9074        }
9075        LastChange::GnOp {
9076            op,
9077            forward,
9078            inserted,
9079        } => {
9080            gn_operate(ed, Some(op), forward, 1);
9081            if let Some(text) = inserted {
9082                replay_insert_and_finish(ed, &text);
9083            }
9084        }
9085        LastChange::ReplaceMode { text } => {
9086            use hjkl_buffer::{Edit, MotionKind, Position};
9087            ed.push_undo();
9088            for ch in text.chars() {
9089                let cursor = buf_cursor_pos(&ed.buffer);
9090                let line_chars = buf_line_chars(&ed.buffer, cursor.row);
9091                if cursor.col < line_chars {
9092                    // Overtype the char under the cursor.
9093                    ed.mutate_edit(Edit::DeleteRange {
9094                        start: cursor,
9095                        end: Position::new(cursor.row, cursor.col + 1),
9096                        kind: MotionKind::Char,
9097                    });
9098                }
9099                ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
9100                buf_set_cursor_rc(&mut ed.buffer, cursor.row, cursor.col + 1);
9101            }
9102            // Esc step-back onto the last overtyped char.
9103            if ed.cursor().1 > 0 {
9104                crate::motions::move_left(&mut ed.buffer, 1);
9105            }
9106            ed.push_buffer_cursor_to_textarea();
9107        }
9108        LastChange::DeleteToEol { inserted } => {
9109            use hjkl_buffer::{Edit, Position};
9110            ed.push_undo();
9111            delete_to_eol(ed);
9112            if let Some(text) = inserted {
9113                let cursor = ed.cursor();
9114                ed.mutate_edit(Edit::InsertStr {
9115                    at: Position::new(cursor.0, cursor.1),
9116                    text,
9117                });
9118            }
9119        }
9120        LastChange::OpenLine { above, inserted } => {
9121            use hjkl_buffer::{Edit, Position};
9122            ed.push_undo();
9123            ed.sync_buffer_content_from_textarea();
9124            let row = buf_cursor_pos(&ed.buffer).row;
9125            if above {
9126                ed.mutate_edit(Edit::InsertStr {
9127                    at: Position::new(row, 0),
9128                    text: "\n".to_string(),
9129                });
9130                let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
9131                crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
9132            } else {
9133                let line_chars = buf_line_chars(&ed.buffer, row);
9134                ed.mutate_edit(Edit::InsertStr {
9135                    at: Position::new(row, line_chars),
9136                    text: "\n".to_string(),
9137                });
9138            }
9139            ed.push_buffer_cursor_to_textarea();
9140            let cursor = ed.cursor();
9141            ed.mutate_edit(Edit::InsertStr {
9142                at: Position::new(cursor.0, cursor.1),
9143                text: inserted,
9144            });
9145        }
9146        LastChange::InsertAt {
9147            entry,
9148            inserted,
9149            count,
9150        } => {
9151            use hjkl_buffer::{Edit, Position};
9152            ed.push_undo();
9153            match entry {
9154                InsertEntry::I => {}
9155                InsertEntry::ShiftI => move_first_non_whitespace(ed),
9156                InsertEntry::A => {
9157                    crate::motions::move_right_to_end(&mut ed.buffer, 1);
9158                    ed.push_buffer_cursor_to_textarea();
9159                }
9160                InsertEntry::ShiftA => {
9161                    crate::motions::move_line_end(&mut ed.buffer);
9162                    crate::motions::move_right_to_end(&mut ed.buffer, 1);
9163                    ed.push_buffer_cursor_to_textarea();
9164                }
9165            }
9166            for _ in 0..count.max(1) {
9167                let cursor = ed.cursor();
9168                ed.mutate_edit(Edit::InsertStr {
9169                    at: Position::new(cursor.0, cursor.1),
9170                    text: inserted.clone(),
9171                });
9172            }
9173        }
9174    }
9175    ed.vim.replaying = false;
9176}
9177
9178// ─── Extracting inserted text for replay ───────────────────────────────────
9179
9180/// The substring of `after` that differs from `before` (first-diff to
9181/// last-diff). Unlike [`extract_inserted`] this works for equal-length or
9182/// shorter results, so it captures `R` overstrike text for dot-repeat.
9183fn changed_run(before: &str, after: &str) -> String {
9184    let a: Vec<char> = before.chars().collect();
9185    let b: Vec<char> = after.chars().collect();
9186    let prefix = a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count();
9187    let max_suffix = a.len().min(b.len()) - prefix;
9188    let suffix = a
9189        .iter()
9190        .rev()
9191        .zip(b.iter().rev())
9192        .take(max_suffix)
9193        .take_while(|(x, y)| x == y)
9194        .count();
9195    b[prefix..b.len() - suffix].iter().collect()
9196}
9197
9198fn extract_inserted(before: &str, after: &str) -> String {
9199    let before_chars: Vec<char> = before.chars().collect();
9200    let after_chars: Vec<char> = after.chars().collect();
9201    if after_chars.len() <= before_chars.len() {
9202        return String::new();
9203    }
9204    let prefix = before_chars
9205        .iter()
9206        .zip(after_chars.iter())
9207        .take_while(|(a, b)| a == b)
9208        .count();
9209    let max_suffix = before_chars.len() - prefix;
9210    let suffix = before_chars
9211        .iter()
9212        .rev()
9213        .zip(after_chars.iter().rev())
9214        .take(max_suffix)
9215        .take_while(|(a, b)| a == b)
9216        .count();
9217    after_chars[prefix..after_chars.len() - suffix]
9218        .iter()
9219        .collect()
9220}
9221
9222// ─── Tests ────────────────────────────────────────────────────────────────
9223
9224#[cfg(test)]
9225mod comment_continuation_tests {
9226    use super::*;
9227    use crate::{DefaultHost, Editor, Options};
9228    use hjkl_buffer::Buffer;
9229
9230    fn make_editor_with_lang(lang: &str, content: &str) -> Editor<Buffer, DefaultHost> {
9231        let buf = Buffer::from_str(content);
9232        let host = DefaultHost::new();
9233        let opts = Options {
9234            filetype: lang.to_string(),
9235            formatoptions: "ro".to_string(),
9236            ..Options::default()
9237        };
9238        Editor::new(buf, host, opts)
9239    }
9240
9241    #[test]
9242    fn detect_rust_doc_comment() {
9243        let result = detect_comment_on_line("rust", "/// foo bar");
9244        assert!(result.is_some());
9245        let (indent, prefix) = result.unwrap();
9246        assert_eq!(indent, "");
9247        assert_eq!(prefix, "/// ");
9248    }
9249
9250    #[test]
9251    fn detect_rust_inner_doc_comment() {
9252        let result = detect_comment_on_line("rust", "//! crate docs");
9253        assert!(result.is_some());
9254        let (_, prefix) = result.unwrap();
9255        assert_eq!(prefix, "//! ");
9256    }
9257
9258    #[test]
9259    fn detect_rust_plain_comment() {
9260        let result = detect_comment_on_line("rust", "// normal comment");
9261        assert!(result.is_some());
9262        let (_, prefix) = result.unwrap();
9263        assert_eq!(prefix, "// ");
9264    }
9265
9266    #[test]
9267    fn detect_indented_comment() {
9268        let result = detect_comment_on_line("rust", "    // indented");
9269        assert!(result.is_some());
9270        let (indent, prefix) = result.unwrap();
9271        assert_eq!(indent, "    ");
9272        assert_eq!(prefix, "// ");
9273    }
9274
9275    #[test]
9276    fn detect_python_hash() {
9277        let result = detect_comment_on_line("python", "# comment");
9278        assert!(result.is_some());
9279        let (_, prefix) = result.unwrap();
9280        assert_eq!(prefix, "# ");
9281    }
9282
9283    #[test]
9284    fn detect_lua_double_dash() {
9285        let result = detect_comment_on_line("lua", "-- a lua comment");
9286        assert!(result.is_some());
9287        let (_, prefix) = result.unwrap();
9288        assert_eq!(prefix, "-- ");
9289    }
9290
9291    #[test]
9292    fn detect_non_comment_is_none() {
9293        assert!(detect_comment_on_line("rust", "let x = 1;").is_none());
9294        assert!(detect_comment_on_line("python", "x = 1").is_none());
9295    }
9296
9297    #[test]
9298    fn detect_bare_double_slash_still_matches() {
9299        // A line that is exactly `//` with nothing after.
9300        assert!(detect_comment_on_line("rust", "//").is_some());
9301    }
9302
9303    #[test]
9304    fn rust_doc_before_plain() {
9305        // `///` must match before `//`.
9306        let result = detect_comment_on_line("rust", "/// outer doc");
9307        let (_, prefix) = result.unwrap();
9308        assert_eq!(prefix, "/// ", "/// must match before //");
9309    }
9310
9311    #[test]
9312    fn continue_comment_returns_prefix_for_comment_row() {
9313        let ed = make_editor_with_lang("rust", "/// hello\n");
9314        let cont = continue_comment(&ed.buffer, &ed.settings, 0);
9315        assert_eq!(cont, Some("/// ".to_string()));
9316    }
9317
9318    #[test]
9319    fn continue_comment_returns_none_for_non_comment() {
9320        let ed = make_editor_with_lang("rust", "let x = 1;\n");
9321        let cont = continue_comment(&ed.buffer, &ed.settings, 0);
9322        assert!(cont.is_none());
9323    }
9324
9325    #[test]
9326    fn continue_comment_returns_none_when_filetype_empty() {
9327        let buf = Buffer::from_str("// hello\n");
9328        let host = DefaultHost::new();
9329        // filetype defaults to "" in Options::default().
9330        let ed = Editor::new(buf, host, Options::default());
9331        let cont = continue_comment(&ed.buffer, &ed.settings, 0);
9332        assert!(cont.is_none());
9333    }
9334}
9335
9336#[cfg(test)]
9337mod comment_toggle_tests {
9338    use super::*;
9339    use crate::{DefaultHost, Editor, Options};
9340    use hjkl_buffer::Buffer;
9341
9342    fn make_rust_editor(content: &str) -> Editor<Buffer, DefaultHost> {
9343        let buf = Buffer::from_str(content);
9344        let host = DefaultHost::new();
9345        let opts = Options {
9346            filetype: "rust".to_string(),
9347            ..Options::default()
9348        };
9349        Editor::new(buf, host, opts)
9350    }
9351
9352    fn line(ed: &Editor<Buffer, DefaultHost>, row: usize) -> String {
9353        buf_line(&ed.buffer, row).unwrap_or_default()
9354    }
9355
9356    // ── gcc: toggle comment on current line ──────────────────────────────────
9357
9358    #[test]
9359    fn gcc_comments_rust_line() {
9360        let mut ed = make_rust_editor("let x = 1;");
9361        ed.toggle_comment_range(0, 0);
9362        assert_eq!(line(&ed, 0), "// let x = 1;");
9363    }
9364
9365    #[test]
9366    fn gcc_uncomments_rust_line() {
9367        let mut ed = make_rust_editor("// let x = 1;");
9368        ed.toggle_comment_range(0, 0);
9369        assert_eq!(line(&ed, 0), "let x = 1;");
9370    }
9371
9372    #[test]
9373    fn gcc_indent_preserving() {
9374        // Marker inserted after leading whitespace, not at column 0.
9375        let mut ed = make_rust_editor("    let x = 1;");
9376        ed.toggle_comment_range(0, 0);
9377        assert_eq!(line(&ed, 0), "    // let x = 1;");
9378    }
9379
9380    #[test]
9381    fn gcc_indent_preserving_uncomment() {
9382        let mut ed = make_rust_editor("    // let x = 1;");
9383        ed.toggle_comment_range(0, 0);
9384        assert_eq!(line(&ed, 0), "    let x = 1;");
9385    }
9386
9387    // ── Multi-line toggle ────────────────────────────────────────────────────
9388
9389    #[test]
9390    fn toggle_multi_line_all_uncommented() {
9391        let content = "let a = 1;\nlet b = 2;\nlet c = 3;";
9392        let mut ed = make_rust_editor(content);
9393        ed.toggle_comment_range(0, 2);
9394        assert_eq!(line(&ed, 0), "// let a = 1;");
9395        assert_eq!(line(&ed, 1), "// let b = 2;");
9396        assert_eq!(line(&ed, 2), "// let c = 3;");
9397    }
9398
9399    #[test]
9400    fn toggle_multi_line_all_commented() {
9401        let content = "// let a = 1;\n// let b = 2;\n// let c = 3;";
9402        let mut ed = make_rust_editor(content);
9403        ed.toggle_comment_range(0, 2);
9404        assert_eq!(line(&ed, 0), "let a = 1;");
9405        assert_eq!(line(&ed, 1), "let b = 2;");
9406        assert_eq!(line(&ed, 2), "let c = 3;");
9407    }
9408
9409    // ── Mixed state → all gets commented (vim-commentary parity) ────────────
9410
9411    #[test]
9412    fn toggle_mixed_state_comments_all() {
9413        // 3 uncommented + 2 commented → all 5 get commented.
9414        let content = "let a = 1;\n// let b = 2;\nlet c = 3;\n// let d = 4;\nlet e = 5;";
9415        let mut ed = make_rust_editor(content);
9416        ed.toggle_comment_range(0, 4);
9417        for r in 0..5 {
9418            assert!(
9419                line(&ed, r).trim_start().starts_with("//"),
9420                "row {r} not commented: {:?}",
9421                line(&ed, r)
9422            );
9423        }
9424    }
9425
9426    // ── Blank lines skipped ──────────────────────────────────────────────────
9427
9428    #[test]
9429    fn blank_lines_not_commented() {
9430        let content = "let a = 1;\n\nlet b = 2;";
9431        let mut ed = make_rust_editor(content);
9432        ed.toggle_comment_range(0, 2);
9433        assert_eq!(line(&ed, 0), "// let a = 1;");
9434        assert_eq!(line(&ed, 1), ""); // blank — untouched
9435        assert_eq!(line(&ed, 2), "// let b = 2;");
9436    }
9437
9438    // ── Python hash comments ─────────────────────────────────────────────────
9439
9440    #[test]
9441    fn python_comment_toggle() {
9442        let buf = Buffer::from_str("x = 1\ny = 2");
9443        let host = DefaultHost::new();
9444        let opts = Options {
9445            filetype: "python".to_string(),
9446            ..Options::default()
9447        };
9448        let mut ed = Editor::new(buf, host, opts);
9449        ed.toggle_comment_range(0, 1);
9450        assert_eq!(line(&ed, 0), "# x = 1");
9451        assert_eq!(line(&ed, 1), "# y = 2");
9452        // Toggle back.
9453        ed.toggle_comment_range(0, 1);
9454        assert_eq!(line(&ed, 0), "x = 1");
9455        assert_eq!(line(&ed, 1), "y = 2");
9456    }
9457
9458    // ── commentstring override ───────────────────────────────────────────────
9459
9460    #[test]
9461    fn commentstring_override_via_setting() {
9462        let buf = Buffer::from_str("hello world");
9463        let host = DefaultHost::new();
9464        let opts = Options {
9465            filetype: "rust".to_string(),
9466            ..Options::default()
9467        };
9468        let mut ed = Editor::new(buf, host, opts);
9469        // Override with a custom marker.
9470        ed.settings_mut().commentstring = "# %s".to_string();
9471        ed.toggle_comment_range(0, 0);
9472        assert_eq!(line(&ed, 0), "# hello world");
9473    }
9474
9475    // ── Unknown language → no-op ─────────────────────────────────────────────
9476
9477    #[test]
9478    fn unknown_lang_no_op() {
9479        let buf = Buffer::from_str("hello");
9480        let host = DefaultHost::new();
9481        let opts = Options::default(); // filetype = ""
9482        let mut ed = Editor::new(buf, host, opts);
9483        ed.toggle_comment_range(0, 0);
9484        // Should be unchanged — no comment string for "".
9485        assert_eq!(line(&ed, 0), "hello");
9486    }
9487}
9488
9489// ─── g& tests ─────────────────────────────────────────────────────────────
9490
9491#[cfg(test)]
9492mod g_ampersand_tests {
9493    use super::*;
9494    use crate::{DefaultHost, Editor, Options};
9495    use hjkl_buffer::{Buffer, rope_line_str};
9496
9497    fn make_editor(content: &str) -> Editor<Buffer, DefaultHost> {
9498        let buf = Buffer::from_str(content);
9499        let host = DefaultHost::new();
9500        Editor::new(buf, host, Options::default())
9501    }
9502
9503    fn buf_line(ed: &Editor<Buffer, DefaultHost>, row: usize) -> String {
9504        let rope = ed.buffer().rope();
9505        rope_line_str(&rope, row).trim_end_matches('\n').to_string()
9506    }
9507
9508    /// `g&` repeats last `:s/foo/bar/` over every line (no /g flag → first
9509    /// match per line only).
9510    #[test]
9511    fn g_ampersand_repeats_last_substitute_on_whole_buffer() {
9512        let mut ed = make_editor("foo\nfoo bar foo\nbaz");
9513        // Simulate a prior `:s/foo/bar/` by setting last_substitute directly.
9514        let cmd = crate::substitute::parse_substitute("/foo/bar/").unwrap();
9515        ed.set_last_substitute(cmd);
9516        // Cursor on line 0 (to confirm g& operates on ALL lines, not just current).
9517        apply_after_g(&mut ed, '&', 1);
9518        assert_eq!(buf_line(&ed, 0), "bar");
9519        // No /g flag — only first match per line.
9520        assert_eq!(buf_line(&ed, 1), "bar bar foo");
9521        assert_eq!(buf_line(&ed, 2), "baz");
9522    }
9523
9524    /// `g&` with /g flag replaces all matches per line.
9525    #[test]
9526    fn g_ampersand_with_g_flag_replaces_all_per_line() {
9527        let mut ed = make_editor("foo foo\nfoo");
9528        let cmd = crate::substitute::parse_substitute("/foo/bar/g").unwrap();
9529        ed.set_last_substitute(cmd);
9530        apply_after_g(&mut ed, '&', 1);
9531        assert_eq!(buf_line(&ed, 0), "bar bar");
9532        assert_eq!(buf_line(&ed, 1), "bar");
9533    }
9534
9535    /// `g&` with no prior substitute is a no-op.
9536    #[test]
9537    fn g_ampersand_noop_when_no_prior_substitute() {
9538        let mut ed = make_editor("foo\nbar");
9539        // No last_substitute set — must not panic, must not change buffer.
9540        apply_after_g(&mut ed, '&', 1);
9541        assert_eq!(buf_line(&ed, 0), "foo");
9542        assert_eq!(buf_line(&ed, 1), "bar");
9543    }
9544}
9545
9546// ─── Sneak motion tests ───────────────────────────────────────────────────
9547
9548#[cfg(test)]
9549mod sneak_tests {
9550    use super::*;
9551    use crate::{DefaultHost, Editor, Options};
9552    use hjkl_buffer::Buffer;
9553
9554    fn make_editor(content: &str) -> Editor<Buffer, DefaultHost> {
9555        let buf = Buffer::from_str(content);
9556        let host = DefaultHost::new();
9557        Editor::new(buf, host, Options::default())
9558    }
9559
9560    /// `s ba` from [0,0] on "foo bar baz qux\n" → cursor at [0,4] (start of "ba" in "bar").
9561    #[test]
9562    fn sneak_forward_jumps_to_two_char_digraph() {
9563        let mut ed = make_editor("foo bar baz qux\n");
9564        ed.jump_cursor(0, 0);
9565        ed.sneak('b', 'a', true, 1);
9566        assert_eq!(ed.cursor(), (0, 4), "cursor should land on 'ba' in 'bar'");
9567    }
9568
9569    /// `S ba` from [0,12] on "foo bar baz qux\n" → cursor at [0,8] ("ba" in "baz").
9570    #[test]
9571    fn sneak_backward_jumps_to_prior_match() {
9572        let mut ed = make_editor("foo bar baz qux\n");
9573        ed.jump_cursor(0, 12);
9574        ed.sneak('b', 'a', false, 1);
9575        assert_eq!(
9576            ed.cursor(),
9577            (0, 8),
9578            "backward sneak should find 'ba' in 'baz'"
9579        );
9580    }
9581
9582    /// After sneak forward to "bar", `;` (sneak-repeat) jumps to next "ba" ("baz").
9583    #[test]
9584    fn sneak_repeat_semicolon_next_match() {
9585        let mut ed = make_editor("foo bar baz qux\n");
9586        ed.jump_cursor(0, 0);
9587        // First sneak: lands at [0,4]
9588        ed.sneak('b', 'a', true, 1);
9589        assert_eq!(ed.cursor(), (0, 4));
9590        // Repeat via execute_motion FindRepeat (which routes through sneak if last was sneak)
9591        execute_motion(&mut ed, Motion::FindRepeat { reverse: false }, 1);
9592        assert_eq!(ed.cursor(), (0, 8), "semicolon should jump to next 'ba'");
9593    }
9594
9595    /// After sneak forward from [0,0] to [0,4], `,` (reverse) — no prior "ba" → stays.
9596    #[test]
9597    fn sneak_repeat_comma_prev_match() {
9598        let mut ed = make_editor("foo bar baz qux\n");
9599        ed.jump_cursor(0, 0);
9600        ed.sneak('b', 'a', true, 1);
9601        assert_eq!(ed.cursor(), (0, 4));
9602        // Reverse repeat — no "ba" before col 4, so cursor must not move.
9603        let pre = ed.cursor();
9604        execute_motion(&mut ed, Motion::FindRepeat { reverse: true }, 1);
9605        assert_eq!(
9606            ed.cursor(),
9607            pre,
9608            "comma with no prior match should leave cursor unchanged"
9609        );
9610    }
9611
9612    /// `S ba` from [0,12] jumps backward.
9613    #[test]
9614    fn sneak_s_searches_backward() {
9615        let mut ed = make_editor("foo bar baz qux\n");
9616        ed.jump_cursor(0, 12);
9617        ed.sneak('b', 'a', false, 1);
9618        assert_eq!(ed.cursor(), (0, 8));
9619    }
9620
9621    /// `2s ba` from [0,0] jumps to 2nd "ba" occurrence.
9622    #[test]
9623    fn sneak_with_count_jumps_to_nth() {
9624        let mut ed = make_editor("foo bar baz qux\n");
9625        ed.jump_cursor(0, 0);
9626        ed.sneak('b', 'a', true, 2);
9627        assert_eq!(ed.cursor(), (0, 8), "count=2 should jump to 2nd 'ba'");
9628    }
9629
9630    /// `s xx` with no match — cursor stays put.
9631    #[test]
9632    fn sneak_no_match_cursor_stays() {
9633        let mut ed = make_editor("foo bar baz qux\n");
9634        ed.jump_cursor(0, 0);
9635        let pre = ed.cursor();
9636        ed.sneak('x', 'x', true, 1);
9637        assert_eq!(ed.cursor(), pre, "no match should leave cursor unchanged");
9638    }
9639
9640    /// `dsab` on "hello ab world\n" from [0,0] → deletes up to 'ab', leaving "ab world\n".
9641    #[test]
9642    fn operator_pending_dsab_deletes_to_digraph() {
9643        let mut ed = make_editor("hello ab world\n");
9644        ed.jump_cursor(0, 0);
9645        ed.apply_op_sneak(Operator::Delete, 'a', 'b', true, 1);
9646        // Buffer content after exclusive delete from [0,0] to [0,6] (start of "ab").
9647        let content = ed.content();
9648        assert!(
9649            content.starts_with("ab world"),
9650            "dsab should delete 'hello ' leaving 'ab world'; got: {content:?}"
9651        );
9652    }
9653
9654    /// Cross-line sneak: "foo\nbar baz\n", cursor [0,0], `s ba` → [1,0].
9655    #[test]
9656    fn sneak_cross_line_match() {
9657        let mut ed = make_editor("foo\nbar baz\n");
9658        ed.jump_cursor(0, 0);
9659        ed.sneak('b', 'a', true, 1);
9660        assert_eq!(ed.cursor(), (1, 0), "sneak should cross line boundary");
9661    }
9662
9663    /// `last_sneak` is updated after `sneak()` so `;`/`,` can repeat.
9664    #[test]
9665    fn sneak_updates_last_sneak_state() {
9666        let mut ed = make_editor("foo bar baz\n");
9667        ed.jump_cursor(0, 0);
9668        ed.sneak('b', 'a', true, 1);
9669        let ls = ed.last_sneak();
9670        assert_eq!(
9671            ls,
9672            Some((('b', 'a'), true)),
9673            "last_sneak should record the digraph and direction"
9674        );
9675    }
9676}
9677
9678// ─── [count]>> / [count]<< line-operator count tests ──────────────────────
9679//
9680// vim semantics (captured from `nvim --headless`, mirrored by the
9681// `tier2_indent_count` oracle corpus):
9682//   - `[count]op` operates on `count` lines from the cursor, clamped to the
9683//     buffer end.
9684//   - The implied `count_` motion moves `count - 1` lines down; on the last
9685//     line it can't move, so `[count>=2]>>` / `<<` is a complete no-op (E16).
9686#[cfg(test)]
9687mod indent_count_tests {
9688    use super::*;
9689    use crate::{DefaultHost, Editor, Options};
9690    use hjkl_buffer::Buffer;
9691
9692    fn make_editor(content: &str) -> Editor<Buffer, DefaultHost> {
9693        let buf = Buffer::from_str(content);
9694        let mut ed = Editor::new(buf, DefaultHost::new(), Options::default());
9695        ed.settings_mut().expandtab = true;
9696        ed.settings_mut().shiftwidth = 4;
9697        ed
9698    }
9699
9700    fn content(ed: &Editor<Buffer, DefaultHost>) -> String {
9701        (*ed.buffer().content_joined()).clone()
9702    }
9703
9704    #[test]
9705    fn count_indent_operates_on_n_lines() {
9706        let mut ed = make_editor("a\nb\nc\nd\ne\nf\n");
9707        ed.jump_cursor(0, 0);
9708        execute_line_op(&mut ed, Operator::Indent, 3);
9709        assert_eq!(content(&ed), "    a\n    b\n    c\nd\ne\nf\n");
9710    }
9711
9712    #[test]
9713    fn count_indent_clamps_to_buffer_end() {
9714        let mut ed = make_editor("a\nb\nc\nd\ne\nf\n");
9715        ed.jump_cursor(0, 0);
9716        execute_line_op(&mut ed, Operator::Indent, 10);
9717        assert_eq!(content(&ed), "    a\n    b\n    c\n    d\n    e\n    f\n");
9718    }
9719
9720    #[test]
9721    fn count_outdent_clamps_to_buffer_end() {
9722        let mut ed = make_editor("    a\n    b\n    c\n");
9723        ed.jump_cursor(0, 0);
9724        execute_line_op(&mut ed, Operator::Outdent, 10);
9725        assert_eq!(content(&ed), "a\nb\nc\n");
9726    }
9727
9728    #[test]
9729    fn count_indent_on_last_line_is_noop() {
9730        let mut ed = make_editor("a\nb\nc\n");
9731        ed.jump_cursor(2, 0); // last content line
9732        execute_line_op(&mut ed, Operator::Indent, 5);
9733        assert_eq!(
9734            content(&ed),
9735            "a\nb\nc\n",
9736            "5>> on last line must abort (E16)"
9737        );
9738    }
9739
9740    #[test]
9741    fn count_indent_on_single_line_is_noop() {
9742        let mut ed = make_editor("x\n");
9743        ed.jump_cursor(0, 0);
9744        execute_line_op(&mut ed, Operator::Indent, 5);
9745        assert_eq!(content(&ed), "x\n", "5>> on the only line must abort (E16)");
9746    }
9747
9748    #[test]
9749    fn count_outdent_on_last_line_is_noop() {
9750        let mut ed = make_editor("    a\n    b\n    c\n");
9751        ed.jump_cursor(2, 0);
9752        execute_line_op(&mut ed, Operator::Outdent, 5);
9753        assert_eq!(content(&ed), "    a\n    b\n    c\n");
9754    }
9755
9756    #[test]
9757    fn single_indent_on_last_line_still_works() {
9758        // count == 1 needs no motion, so `>>` on the last line indents it.
9759        let mut ed = make_editor("a\nb\nc\n");
9760        ed.jump_cursor(2, 0);
9761        execute_line_op(&mut ed, Operator::Indent, 1);
9762        assert_eq!(content(&ed), "a\nb\n    c\n");
9763    }
9764}
9765
9766// ── try_abbrev_expand unit tests ─────────────────────────────────────────────
9767
9768#[cfg(test)]
9769mod abbrev_tests {
9770    use super::{Abbrev, AbbrevKind, AbbrevTrigger, abbrev_kind, try_abbrev_expand};
9771    use AbbrevKind::{End, Full, NonKw};
9772
9773    const ISK: &str = "@,48-57,_,192-255"; // default iskeyword
9774
9775    fn make_abbrev(lhs: &str, rhs: &str) -> Abbrev {
9776        Abbrev {
9777            lhs: lhs.to_string(),
9778            rhs: rhs.to_string(),
9779            insert: true,
9780            cmdline: false,
9781            noremap: false,
9782        }
9783    }
9784
9785    fn expand(
9786        abbrevs: &[Abbrev],
9787        before: &str,
9788        mincol: usize,
9789        trig: AbbrevTrigger,
9790    ) -> Option<(usize, String)> {
9791        try_abbrev_expand(abbrevs, before, mincol, trig, ISK)
9792    }
9793
9794    // ── abbrev_type classification ────────────────────────────────────────────
9795
9796    #[test]
9797    fn fullid_all_keyword_chars() {
9798        assert_eq!(abbrev_kind("teh", ISK), Full);
9799        assert_eq!(abbrev_kind("abc123", ISK), Full);
9800        assert_eq!(abbrev_kind("_foo", ISK), Full);
9801    }
9802
9803    #[test]
9804    fn endid_ends_with_kw_has_nonkw() {
9805        assert_eq!(abbrev_kind("#i", ISK), End);
9806        assert_eq!(abbrev_kind("#include", ISK), End);
9807    }
9808
9809    #[test]
9810    fn nonid_ends_with_nonkw() {
9811        assert_eq!(abbrev_kind(";;", ISK), NonKw);
9812        assert_eq!(abbrev_kind("->", ISK), NonKw);
9813    }
9814
9815    // ── full-id expansion ─────────────────────────────────────────────────────
9816
9817    #[test]
9818    fn fullid_expands_on_space_trigger() {
9819        let abbrevs = [make_abbrev("teh", "the")];
9820        let r = expand(&abbrevs, "teh", 0, AbbrevTrigger::NonKeyword(' '));
9821        assert_eq!(r, Some((3, "the".to_string())));
9822    }
9823
9824    #[test]
9825    fn fullid_expands_on_esc_trigger() {
9826        let abbrevs = [make_abbrev("teh", "the")];
9827        let r = expand(&abbrevs, "teh", 0, AbbrevTrigger::Esc);
9828        assert_eq!(r, Some((3, "the".to_string())));
9829    }
9830
9831    #[test]
9832    fn fullid_expands_on_cr_trigger() {
9833        let abbrevs = [make_abbrev("teh", "the")];
9834        let r = expand(&abbrevs, "teh", 0, AbbrevTrigger::Cr);
9835        assert_eq!(r, Some((3, "the".to_string())));
9836    }
9837
9838    #[test]
9839    fn fullid_expands_on_ctrl_bracket() {
9840        let abbrevs = [make_abbrev("teh", "the")];
9841        let r = expand(&abbrevs, "teh", 0, AbbrevTrigger::CtrlBracket);
9842        assert_eq!(r, Some((3, "the".to_string())));
9843    }
9844
9845    #[test]
9846    fn fullid_does_not_expand_on_keyword_trigger() {
9847        // Typing a keyword char after "teh" would extend the word — no expand.
9848        let abbrevs = [make_abbrev("teh", "the")];
9849        let r = expand(&abbrevs, "teh", 0, AbbrevTrigger::NonKeyword('a'));
9850        // 'a' is keyword — should not trigger
9851        assert_eq!(r, None);
9852    }
9853
9854    #[test]
9855    fn fullid_no_expand_when_lhs_not_at_end() {
9856        let abbrevs = [make_abbrev("teh", "the")];
9857        // "ateh" — 'a' before is keyword, so skip.
9858        let r = expand(&abbrevs, "ateh", 0, AbbrevTrigger::NonKeyword(' '));
9859        assert_eq!(r, None);
9860    }
9861
9862    #[test]
9863    fn fullid_expands_after_nonkw_prefix() {
9864        let abbrevs = [make_abbrev("teh", "the")];
9865        // "!teh" — '!' before is non-keyword → expand.
9866        let r = expand(&abbrevs, "!teh", 0, AbbrevTrigger::NonKeyword(' '));
9867        assert_eq!(r, Some((3, "the".to_string())));
9868    }
9869
9870    #[test]
9871    fn fullid_single_char_no_expand_after_nonblank_nonkw() {
9872        let abbrevs = [make_abbrev("a", "b")];
9873        // "!a" — '!' is non-blank non-keyword before single-char lhs → no expand.
9874        let r = expand(&abbrevs, "!a", 0, AbbrevTrigger::NonKeyword(' '));
9875        assert_eq!(r, None);
9876    }
9877
9878    #[test]
9879    fn fullid_single_char_expands_after_space() {
9880        let abbrevs = [make_abbrev("a", "b")];
9881        // " a" — space before single-char lhs → expand.
9882        let r = expand(&abbrevs, " a", 0, AbbrevTrigger::NonKeyword(' '));
9883        assert_eq!(r, Some((1, "b".to_string())));
9884    }
9885
9886    // ── mincol: pre-existing text must not be consumed ────────────────────────
9887
9888    #[test]
9889    fn mincol_blocks_consuming_preexisting_text() {
9890        let abbrevs = [make_abbrev("teh", "the")];
9891        // "teh" is at cols 0..3, but insert started at col 3 → no match.
9892        let r = expand(&abbrevs, "teh", 3, AbbrevTrigger::NonKeyword(' '));
9893        assert_eq!(r, None);
9894    }
9895
9896    #[test]
9897    fn mincol_allows_match_starting_at_mincol() {
9898        let abbrevs = [make_abbrev("teh", "the")];
9899        // Existing text "!! " at 0..3, then user typed "teh" → mincol=3.
9900        // The char before the lhs is ' ' (non-keyword), so full-id expands.
9901        let r = expand(&abbrevs, "!! teh", 3, AbbrevTrigger::NonKeyword(' '));
9902        assert_eq!(r, Some((3, "the".to_string())));
9903    }
9904
9905    // ── end-id expansion ──────────────────────────────────────────────────────
9906
9907    #[test]
9908    fn endid_expands_on_space_trigger() {
9909        let abbrevs = [make_abbrev("#i", "#include")];
9910        let r = expand(&abbrevs, "#i", 0, AbbrevTrigger::NonKeyword(' '));
9911        assert_eq!(r, Some((2, "#include".to_string())));
9912    }
9913
9914    #[test]
9915    fn endid_expands_on_esc_trigger() {
9916        let abbrevs = [make_abbrev("#i", "#include")];
9917        let r = expand(&abbrevs, "#i", 0, AbbrevTrigger::Esc);
9918        assert_eq!(r, Some((2, "#include".to_string())));
9919    }
9920
9921    // ── non-id expansion ──────────────────────────────────────────────────────
9922
9923    #[test]
9924    fn nonid_expands_on_esc_trigger() {
9925        let abbrevs = [make_abbrev(";;", "std::endl;")];
9926        let r = expand(&abbrevs, ";;", 0, AbbrevTrigger::Esc);
9927        assert_eq!(r, Some((2, "std::endl;".to_string())));
9928    }
9929
9930    #[test]
9931    fn nonid_expands_on_cr_trigger() {
9932        let abbrevs = [make_abbrev(";;", "std::endl;")];
9933        let r = expand(&abbrevs, ";;", 0, AbbrevTrigger::Cr);
9934        assert_eq!(r, Some((2, "std::endl;".to_string())));
9935    }
9936
9937    #[test]
9938    fn nonid_does_not_expand_on_nonkw_trigger() {
9939        // non-id abbreviations must NOT expand on regular typed chars like space.
9940        let abbrevs = [make_abbrev(";;", "std::endl;")];
9941        let r = expand(&abbrevs, ";;", 0, AbbrevTrigger::NonKeyword(' '));
9942        assert_eq!(r, None);
9943    }
9944
9945    #[test]
9946    fn nonid_expands_on_ctrl_bracket() {
9947        let abbrevs = [make_abbrev(";;", "std::endl;")];
9948        let r = expand(&abbrevs, ";;", 0, AbbrevTrigger::CtrlBracket);
9949        assert_eq!(r, Some((2, "std::endl;".to_string())));
9950    }
9951
9952    // ── multiword rhs ─────────────────────────────────────────────────────────
9953
9954    #[test]
9955    fn multiword_rhs_expansion() {
9956        let abbrevs = [make_abbrev("hw", "hello world")];
9957        let r = expand(&abbrevs, "hw", 0, AbbrevTrigger::NonKeyword(' '));
9958        assert_eq!(r, Some((2, "hello world".to_string())));
9959    }
9960
9961    // ── empty / no match ─────────────────────────────────────────────────────
9962
9963    #[test]
9964    fn no_match_returns_none() {
9965        let abbrevs = [make_abbrev("teh", "the")];
9966        let r = expand(&abbrevs, "xyz", 0, AbbrevTrigger::NonKeyword(' '));
9967        assert_eq!(r, None);
9968    }
9969
9970    #[test]
9971    fn empty_abbrevs_returns_none() {
9972        let r = expand(&[], "teh", 0, AbbrevTrigger::NonKeyword(' '));
9973        assert_eq!(r, None);
9974    }
9975
9976    #[test]
9977    fn empty_before_text_returns_none() {
9978        let abbrevs = [make_abbrev("teh", "the")];
9979        let r = expand(&abbrevs, "", 0, AbbrevTrigger::NonKeyword(' '));
9980        assert_eq!(r, None);
9981    }
9982}