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