Skip to main content

hjkl_vim/vim/
motion.rs

1//! Vim FSM: motion.
2//!
3//! Split out of the monolithic `vim.rs` (#267 follow-up).
4
5use hjkl_vim_types::{LastHorizontalMotion, Mode, Motion};
6
7use hjkl_engine::input::{Input, Key};
8
9use super::*;
10use crate::vim_state::{vim, vim_mut};
11use hjkl_engine::buf_helpers::{
12    buf_cursor_pos, buf_line, buf_line_chars, buf_row_count, buf_set_cursor_rc,
13};
14use hjkl_engine::{Editor, Move};
15
16/// Parse the first key of a normal/visual-mode motion. Returns `None` for
17/// keys that don't start a motion (operator keys, command keys, etc.).
18/// Promoted to `pub` in Phase 6.6e so `hjkl-vim::normal` can call it.
19pub fn parse_motion(input: &Input) -> Option<Motion> {
20    if input.ctrl {
21        // `<C-h>` is vim's `<BS>` — a wrapping left motion. (The hjkl app
22        // rebinds `<C-h>` to window-focus-left before it reaches the engine;
23        // this keeps it correct for engine consumers that don't override it.)
24        if input.key == Key::Char('h') {
25            return Some(Motion::BackspaceBack);
26        }
27        return None;
28    }
29    match input.key {
30        Key::Char('h') | Key::Left => Some(Motion::Left),
31        Key::Char('l') | Key::Right => Some(Motion::Right),
32        // `<Space>`/`<BS>` are vim's right/left motions that WRAP at line ends
33        // (default `whichwrap=b,s`), unlike `l`/`h`/arrows which never wrap.
34        // Operators (`d<Space>`/`d<BS>`) act on one char mid-line like `dl`/`dh`.
35        Key::Char(' ') => Some(Motion::SpaceFwd),
36        Key::Backspace => Some(Motion::BackspaceBack),
37        Key::Char('j') | Key::Down => Some(Motion::Down),
38        // `+` / `<CR>` — first non-blank of next line (linewise, count-aware).
39        Key::Char('+') | Key::Enter => Some(Motion::FirstNonBlankNextLine),
40        // `-` — first non-blank of previous line (linewise, count-aware).
41        Key::Char('-') => Some(Motion::FirstNonBlankPrevLine),
42        // `_` — first non-blank of current line, or count-1 lines down (linewise).
43        Key::Char('_') => Some(Motion::FirstNonBlankLine),
44        Key::Char('k') | Key::Up => Some(Motion::Up),
45        Key::Char('w') => Some(Motion::WordFwd),
46        Key::Char('W') => Some(Motion::BigWordFwd),
47        Key::Char('b') => Some(Motion::WordBack),
48        Key::Char('B') => Some(Motion::BigWordBack),
49        Key::Char('e') => Some(Motion::WordEnd),
50        Key::Char('E') => Some(Motion::BigWordEnd),
51        Key::Char('0') | Key::Home => Some(Motion::LineStart),
52        Key::Char('^') => Some(Motion::FirstNonBlank),
53        Key::Char('$') | Key::End => Some(Motion::LineEnd),
54        Key::Char('G') => Some(Motion::FileBottom),
55        Key::Char('%') => Some(Motion::MatchBracket),
56        Key::Char(';') => Some(Motion::FindRepeat { reverse: false }),
57        Key::Char(',') => Some(Motion::FindRepeat { reverse: true }),
58        Key::Char('*') => Some(Motion::WordAtCursor {
59            forward: true,
60            whole_word: true,
61        }),
62        Key::Char('#') => Some(Motion::WordAtCursor {
63            forward: false,
64            whole_word: true,
65        }),
66        Key::Char('n') => Some(Motion::SearchNext { reverse: false }),
67        Key::Char('N') => Some(Motion::SearchNext { reverse: true }),
68        Key::Char('H') => Some(Motion::ViewportTop),
69        Key::Char('M') => Some(Motion::ViewportMiddle),
70        Key::Char('L') => Some(Motion::ViewportBottom),
71        Key::Char('{') => Some(Motion::ParagraphPrev),
72        Key::Char('}') => Some(Motion::ParagraphNext),
73        Key::Char('(') => Some(Motion::SentencePrev),
74        Key::Char(')') => Some(Motion::SentenceNext),
75        Key::Char('|') => Some(Motion::GotoColumn),
76        _ => None,
77    }
78}
79pub fn execute_motion<H: hjkl_engine::types::Host>(
80    ed: &mut Editor<hjkl_buffer::View, H>,
81    motion: Motion,
82    count: usize,
83) {
84    let count = count.clamp(1, MAX_COUNT);
85    // `;`/`,` smart fallback: if the last horizontal motion was a sneak
86    // digraph, repeat via apply_sneak instead of find-char.
87    if let Motion::FindRepeat { reverse } = motion
88        && vim(ed).last_horizontal_motion == LastHorizontalMotion::Sneak
89    {
90        if let Some(((c1, c2), fwd)) = vim(ed).last_sneak {
91            let effective_fwd = if reverse { !fwd } else { fwd };
92            apply_sneak(ed, c1, c2, effective_fwd, count);
93        }
94        return;
95    }
96    // FindRepeat needs the stored direction. A `;`/`,` repeat of a `t`/`T`
97    // find must skip an immediately-adjacent match (vim's repeat quirk); flag
98    // it so the `Motion::Find` dispatch below passes `skip_adjacent`.
99    let motion = match motion {
100        Motion::FindRepeat { reverse } => match vim(ed).last_find {
101            Some((ch, forward, till)) => {
102                vim_mut(ed).find_repeat_skip = true;
103                Motion::Find {
104                    ch,
105                    forward: if reverse { !forward } else { forward },
106                    till,
107                }
108            }
109            None => return,
110        },
111        other => other,
112    };
113    let pre_pos = ed.cursor();
114    apply_motion_cursor(ed, &motion, count);
115    let post_pos = ed.cursor();
116    if is_big_jump(&motion) && pre_pos != post_pos {
117        ed.push_jump(pre_pos);
118    }
119    // Phase 1 (backlog §1.6): the motion names its own curswant semantics.
120    // The landed cursor is re-moved through the matching `Move` variant —
121    // for the cursor itself this is a no-op (the motion already landed and
122    // clamped), and it rewrites `sticky_col` to the class's rule, replacing
123    // the old post-hoc `apply_sticky_col` catch-all.
124    match motion_class(&motion) {
125        MotionClass::Vertical => {
126            // Re-run the sticky clamp on the landed row. The vertical motion
127            // fns already clamped there, so this only confirms the
128            // un-clamped want stays stored for the next vertical motion.
129            ed.move_cursor(Move::Vertical { row: ed.cursor().0 });
130        }
131        MotionClass::Jump => {
132            // Re-jump to the landed spot; `jump_cursor` sets sticky to the
133            // landed column (vim resets curswant on every explicit jump).
134            let pos = ed.cursor();
135            ed.move_cursor(Move::Jump {
136                row: pos.0,
137                col: pos.1,
138            });
139        }
140        // An `h` / `l` that cannot move fails in vim (`nv_left` / `nv_right`
141        // beep), and a failed command leaves curswant alone: `$lj` still
142        // aims for the end of the line.
143        MotionClass::Horizontal
144            if pre_pos == post_pos
145                && matches!(
146                    motion,
147                    Motion::Left | Motion::Right | Motion::SpaceFwd | Motion::BackspaceBack
148                ) => {}
149        MotionClass::Horizontal => {
150            // Sticky tracks the landed column.
151            ed.move_cursor(Move::Horizontal { col: ed.cursor().1 });
152            // vim's `nv_dollar` sets curswant to MAXCOL instead, so `j` / `k`
153            // after `$` keep to the end of every line they reach. Every
154            // reader clamps the want to the row it lands on.
155            if matches!(motion, Motion::LineEnd) {
156                ed.set_sticky_col(Some(usize::MAX));
157            }
158        }
159    }
160    if motion_opens_fold(&motion, count, pre_pos != post_pos) {
161        foldopen_cursor(ed);
162    }
163    // Phase 7b: keep the migration buffer's cursor + viewport in
164    // lockstep with the textarea after every motion. Once 7c lands
165    // (motions ported onto the buffer's API), this flips: the
166    // buffer becomes authoritative and the textarea mirrors it.
167    ed.sync_buffer_from_textarea();
168}
169/// Wrapper around `execute_motion` that also syncs `block_vcol` when in
170/// VisualBlock mode. The engine FSM's `step()` already does this (line ~2001);
171/// the keymap path (`apply_motion_kind`) must do the same so VisualBlock h/l
172/// extend the highlighted region correctly.
173///
174/// `update_block_vcol` is only a no-op for vertical / non-horizontal motions
175/// (Up, Down, FileTop, FileBottom, Search), so passing every motion through is
176/// safe — the function's own match arm handles the no-op case.
177pub fn execute_motion_with_block_vcol<H: hjkl_engine::types::Host>(
178    ed: &mut Editor<hjkl_buffer::View, H>,
179    motion: Motion,
180    count: usize,
181) {
182    let motion_copy = motion.clone();
183    execute_motion(ed, motion, count);
184    if vim(ed).mode == Mode::VisualBlock {
185        update_block_vcol(ed, &motion_copy);
186    }
187}
188/// Execute a `hjkl_engine::MotionKind` cursor motion. Called by the host's
189/// `Editor::apply_motion` controller method — the keymap dispatch path for
190/// Phase 3a of kryptic-sh/hjkl#69.
191///
192/// Maps each variant to the same internal primitives used by the engine FSM
193/// so cursor, sticky column, scroll, and sync semantics are identical.
194///
195/// # Visual-mode post-motion sync audit (2026-05-13)
196///
197/// After `execute_motion`, two things are conditional on visual mode:
198///
199/// 1. **VisualBlock `block_vcol` sync** — `update_block_vcol(ed, &motion)` is
200///    called when `mode == Mode::VisualBlock`.  This is replicated here via
201///    `execute_motion_with_block_vcol` for every motion variant below.
202///
203/// 2. **`last_find` update** — `Motion::Find` is dispatched through
204///    `Pending::Find → apply_find_char` (in hjkl-vim), which writes `last_find`
205///    itself.  A post-motion `last_find` write here would be dead code.  The keymap
206///    path writes `last_find` in `apply_find_char` (called from
207///    `Editor::find_char`), so no gap exists here.
208///
209/// No VisualLine-specific or Visual-specific post-motion work exists in the
210/// FSM: anchors (`visual_anchor`, `visual_line_anchor`, `block_anchor`) are
211/// only written on mode-entry or `o`-swap, never on motion.  The `<`/`>`
212/// mark update in `step()` fires only on visual→normal transition, not after
213/// each motion.  There are **no further sync gaps** beyond the `block_vcol`
214/// fix already applied above.
215pub fn apply_motion_kind<H: hjkl_engine::types::Host>(
216    ed: &mut Editor<hjkl_buffer::View, H>,
217    kind: hjkl_engine::MotionKind,
218    count: usize,
219) {
220    let count = count.max(1);
221    match kind {
222        hjkl_engine::MotionKind::CharLeft => {
223            execute_motion_with_block_vcol(ed, Motion::Left, count);
224        }
225        hjkl_engine::MotionKind::CharRight => {
226            execute_motion_with_block_vcol(ed, Motion::Right, count);
227        }
228        hjkl_engine::MotionKind::LineDown => {
229            execute_motion_with_block_vcol(ed, Motion::Down, count);
230        }
231        hjkl_engine::MotionKind::LineUp => {
232            execute_motion_with_block_vcol(ed, Motion::Up, count);
233        }
234        hjkl_engine::MotionKind::FirstNonBlankDown => {
235            // `+`: move down `count` lines then land on first non-blank.
236            // Not a big-jump (no jump-list entry). The landed first
237            // non-blank column becomes the sticky target — vim's `+` sets
238            // curswant to where it lands (jump class). Mirrors
239            // scroll_cursor_rows semantics but goes through the fold-aware
240            // buffer motion path.
241            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
242            let mut sticky = ed.sticky_col();
243            let tabstop = ed.settings().tabstop;
244            hjkl_engine::motions::move_down(ed.buffer_mut(), &folds, count, &mut sticky, tabstop);
245            ed.set_sticky_col(sticky);
246            hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
247            let pos = ed.cursor();
248            ed.move_cursor(Move::Jump {
249                row: pos.0,
250                col: pos.1,
251            });
252            ed.sync_buffer_from_textarea();
253        }
254        hjkl_engine::MotionKind::FirstNonBlankUp => {
255            // `-`: move up `count` lines then land on first non-blank.
256            // Same pattern as FirstNonBlankDown, direction reversed.
257            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
258            let mut sticky = ed.sticky_col();
259            let tabstop = ed.settings().tabstop;
260            hjkl_engine::motions::move_up(ed.buffer_mut(), &folds, count, &mut sticky, tabstop);
261            ed.set_sticky_col(sticky);
262            hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
263            let pos = ed.cursor();
264            ed.move_cursor(Move::Jump {
265                row: pos.0,
266                col: pos.1,
267            });
268            ed.sync_buffer_from_textarea();
269        }
270        hjkl_engine::MotionKind::WordForward => {
271            execute_motion_with_block_vcol(ed, Motion::WordFwd, count);
272        }
273        hjkl_engine::MotionKind::BigWordForward => {
274            execute_motion_with_block_vcol(ed, Motion::BigWordFwd, count);
275        }
276        hjkl_engine::MotionKind::WordBackward => {
277            execute_motion_with_block_vcol(ed, Motion::WordBack, count);
278        }
279        hjkl_engine::MotionKind::BigWordBackward => {
280            execute_motion_with_block_vcol(ed, Motion::BigWordBack, count);
281        }
282        hjkl_engine::MotionKind::WordEnd => {
283            execute_motion_with_block_vcol(ed, Motion::WordEnd, count);
284        }
285        hjkl_engine::MotionKind::BigWordEnd => {
286            execute_motion_with_block_vcol(ed, Motion::BigWordEnd, count);
287        }
288        hjkl_engine::MotionKind::LineStart => {
289            // `0` / `<Home>`: first column of the current line.
290            // count is ignored — matches vim `0` semantics.
291            execute_motion_with_block_vcol(ed, Motion::LineStart, 1);
292        }
293        hjkl_engine::MotionKind::FirstNonBlank => {
294            // `^`: first non-blank column on the current line.
295            // count is ignored — matches vim `^` semantics.
296            execute_motion_with_block_vcol(ed, Motion::FirstNonBlank, 1);
297        }
298        hjkl_engine::MotionKind::GotoLine => {
299            // `G`: bare `G` → last line; `count G` → jump to line `count`.
300            // apply_motion_kind normalises the raw count to count.max(1)
301            // above, so count == 1 means "bare G" (last line) and count > 1
302            // means "go to line N". execute_motion's FileBottom arm applies
303            // the same `count > 1` check before calling move_bottom, so the
304            // convention aligns: pass count straight through.
305            // FileBottom is vertical — update_block_vcol is a no-op here
306            // (preserves vcol), so the helper is safe to use.
307            execute_motion_with_block_vcol(ed, Motion::FileBottom, count);
308        }
309        hjkl_engine::MotionKind::LineEnd => {
310            // `[count]$` / `<End>`: move count-1 lines down then land
311            // at the last character of the destination line.
312            execute_motion_with_block_vcol(ed, Motion::LineEnd, count);
313        }
314        hjkl_engine::MotionKind::FindRepeat => {
315            // `;` — repeat last f/F/t/T in the same direction.
316            // execute_motion resolves FindRepeat via vim(ed).last_find;
317            // no-op if no prior find exists (None arm returns early).
318            execute_motion_with_block_vcol(ed, Motion::FindRepeat { reverse: false }, count);
319        }
320        hjkl_engine::MotionKind::FindRepeatReverse => {
321            // `,` — repeat last f/F/t/T in the reverse direction.
322            // execute_motion resolves FindRepeat via vim(ed).last_find;
323            // no-op if no prior find exists (None arm returns early).
324            execute_motion_with_block_vcol(ed, Motion::FindRepeat { reverse: true }, count);
325        }
326        hjkl_engine::MotionKind::BracketMatch => {
327            // `%` — jump to the matching bracket.
328            // count is passed through; engine-side matching_bracket handles
329            // the no-match case as a no-op (cursor stays). Engine FSM arm
330            // for `%` in parse_motion is kept intact for macro-replay.
331            execute_motion_with_block_vcol(ed, Motion::MatchBracket, count);
332        }
333        hjkl_engine::MotionKind::ViewportTop => {
334            // `H` — cursor to top of visible viewport, then count-1 rows down.
335            // Engine FSM arm for `H` in parse_motion is kept intact for macro-replay.
336            execute_motion_with_block_vcol(ed, Motion::ViewportTop, count);
337        }
338        hjkl_engine::MotionKind::ViewportMiddle => {
339            // `M` — cursor to middle of visible viewport; count ignored.
340            // Engine FSM arm for `M` in parse_motion is kept intact for macro-replay.
341            execute_motion_with_block_vcol(ed, Motion::ViewportMiddle, count);
342        }
343        hjkl_engine::MotionKind::ViewportBottom => {
344            // `L` — cursor to bottom of visible viewport, then count-1 rows up.
345            // Engine FSM arm for `L` in parse_motion is kept intact for macro-replay.
346            execute_motion_with_block_vcol(ed, Motion::ViewportBottom, count);
347        }
348        hjkl_engine::MotionKind::HalfPageDown => {
349            // `<C-d>` — half page down, count multiplies the distance.
350            // Calls scroll_cursor_rows directly rather than adding a Motion enum
351            // variant, keeping engine Motion churn minimal.
352            {
353                let d = ed.viewport_half_rows(count) as isize;
354                ed.scroll_cursor_rows(d);
355            }
356        }
357        hjkl_engine::MotionKind::HalfPageUp => {
358            // `<C-u>` — half page up, count multiplies the distance.
359            // Direct call mirrors the FSM Ctrl-u arm. No new Motion variant.
360            {
361                let d = -(ed.viewport_half_rows(count) as isize);
362                ed.scroll_cursor_rows(d);
363            }
364        }
365        hjkl_engine::MotionKind::FullPageDown => {
366            // `<C-f>` — full page down (2-line overlap), count multiplies.
367            // Direct call mirrors the FSM Ctrl-f arm. No new Motion variant.
368            {
369                let d = ed.viewport_full_rows(count) as isize;
370                ed.scroll_cursor_rows(d);
371            }
372        }
373        hjkl_engine::MotionKind::FullPageUp => {
374            // `<C-b>` — full page up (2-line overlap), count multiplies.
375            // Direct call mirrors the FSM Ctrl-b arm. No new Motion variant.
376            {
377                let d = -(ed.viewport_full_rows(count) as isize);
378                ed.scroll_cursor_rows(d);
379            }
380        }
381        hjkl_engine::MotionKind::FirstNonBlankLine => {
382            execute_motion_with_block_vcol(ed, Motion::FirstNonBlankLine, count);
383        }
384        hjkl_engine::MotionKind::SectionBackward => {
385            execute_motion_with_block_vcol(ed, Motion::SectionBackward, count);
386        }
387        hjkl_engine::MotionKind::SectionForward => {
388            execute_motion_with_block_vcol(ed, Motion::SectionForward, count);
389        }
390        hjkl_engine::MotionKind::SectionEndBackward => {
391            execute_motion_with_block_vcol(ed, Motion::SectionEndBackward, count);
392        }
393        hjkl_engine::MotionKind::SectionEndForward => {
394            execute_motion_with_block_vcol(ed, Motion::SectionEndForward, count);
395        }
396        // `MotionKind` is `#[non_exhaustive]` and now lives in another crate, so
397        // the engine can add a motion this FSM has never heard of. Ignoring it
398        // is the honest response: a discipline cannot execute a motion it has
399        // no binding for. Every variant that exists today is handled above.
400        _ => {}
401    }
402}
403/// Which of the three `Move` curswant classes a plain motion belongs to
404/// (backlog §1.6 phase 1). Each variant names its own curswant semantics,
405/// replacing the old post-hoc `apply_sticky_col` catch-all;
406/// `execute_motion` re-moves the landed cursor through the matching `Move`
407/// variant.
408///
409/// There is deliberately no `Raw` class: `Move::Raw` is for pure
410/// repositions owned by surrounding code (operator park/restore, host
411/// state sync), and no plain motion is one — every motion either preserves
412/// the sticky column (Vertical) or syncs it to the landed column (Jump /
413/// Horizontal). The match below is exhaustive, so a Motion variant added
414/// later must be classified here to compile.
415///
416/// See [`hjkl_engine::Move`] for the semantics of each class.
417#[derive(Debug, Clone, Copy, PartialEq, Eq)]
418enum MotionClass {
419    /// `j` / `k` and their screen-line equivalents — preserve the sticky
420    /// column across rows.
421    Vertical,
422    /// Explicit jumps — the landing column is a target, not a relative
423    /// step; vim resets curswant to the landed column.
424    Jump,
425    /// Relative moves within a row (or a row-relative target like `$`, `^`,
426    /// `f`) — sticky tracks the landed column.
427    Horizontal,
428}
429
430fn motion_class(motion: &Motion) -> MotionClass {
431    use MotionClass::*;
432    match motion {
433        // Vertical: only these preserve the sticky column. Everything else
434        // (search, gg / G, word jumps, ...) lands at the match's own column
435        // so the sticky value syncs to the new cursor column.
436        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown => Vertical,
437        // Jump: explicit target landings — `gg`/`G`, `%`, `[(`/`])`,
438        // `*`/`#`, `n`/`N`, `H`/`M`/`L`, `{`/`}`, `(`/`)`, `[[`/`]]`/
439        // `[]`/`][`, and the first-non-blank linewise motions `+`/`-`/`_`
440        // (vim sets curswant to the landed first-non-blank column).
441        Motion::FileTop
442        | Motion::FileBottom
443        | Motion::MatchBracket
444        | Motion::UnmatchedBracket { .. }
445        | Motion::WordAtCursor { .. }
446        | Motion::SearchNext { .. }
447        | Motion::ViewportTop
448        | Motion::ViewportMiddle
449        | Motion::ViewportBottom
450        | Motion::ParagraphPrev
451        | Motion::ParagraphNext
452        | Motion::SentencePrev
453        | Motion::SentenceNext
454        | Motion::SectionBackward
455        | Motion::SectionForward
456        | Motion::SectionEndBackward
457        | Motion::SectionEndForward
458        | Motion::FirstNonBlankNextLine
459        | Motion::FirstNonBlankPrevLine
460        | Motion::FirstNonBlankLine => Jump,
461        // Horizontal: moves whose landed column is the sticky target on the
462        // same row. `FindRepeat` is resolved to `Find` (or the sneak
463        // early-return) before it ever reaches the classifier.
464        Motion::Left
465        | Motion::Right
466        | Motion::SpaceFwd
467        | Motion::BackspaceBack
468        | Motion::WordFwd
469        | Motion::BigWordFwd
470        | Motion::WordBack
471        | Motion::BigWordBack
472        | Motion::WordEnd
473        | Motion::BigWordEnd
474        | Motion::ChangeWordEnd { .. }
475        | Motion::WordEndBack
476        | Motion::BigWordEndBack
477        | Motion::LineStart
478        | Motion::FirstNonBlank
479        | Motion::LineEnd
480        | Motion::Find { .. }
481        | Motion::FindRepeat { .. }
482        | Motion::LastNonBlank
483        | Motion::LineMiddle
484        | Motion::ScreenLineMiddle
485        | Motion::GotoColumn => Horizontal,
486        // No Motion variant classifies Raw — see the enum docs.
487    }
488}
489/// Vim's `foldOpenCursor()` as `'foldopen'` runs it after a command: open
490/// every closed fold holding the cursor row. Vim only does this for a command
491/// typed in Normal mode — not under an operator (callers on an operator path
492/// never get here), not in Visual, and not while `@q` replays keys
493/// (`KeyTyped` is false there) — so the gate lives here, once.
494pub fn foldopen_cursor<H: hjkl_engine::types::Host>(ed: &mut Editor<hjkl_buffer::View, H>) {
495    let st = vim(ed);
496    if st.mode != Mode::Normal || st.replaying_macro {
497        return;
498    }
499    let row = ed.cursor().0;
500    ed.buffer_mut().open_folds_containing(row);
501}
502/// Whether vim's default `'foldopen'` (`block,hor,mark,percent,quickfix,
503/// search,tag,undo`) opens the folds under the cursor once `motion` finishes
504/// as a Normal-mode command. Each arm is the flag vim's own handler checks;
505/// `moved` stands in for the handler's success test, which is why a failed
506/// `l` at the end of a line opens nothing. Measured against nvim 0.12.5:
507/// `ge`/`gE`, `g_`, `gm`/`gM`, `|`, `H`/`M`/`L`, `+`/`-`/`_`, `gg`/`G` and
508/// `j`/`k` leave the fold closed.
509fn motion_opens_fold(motion: &Motion, count: usize, moved: bool) -> bool {
510    match motion {
511        // `hor`: `nv_beginline()` succeeds without moving, and so does
512        // `nv_dollar()` unless a count runs it off the end of the buffer.
513        Motion::LineStart | Motion::FirstNonBlank => true,
514        Motion::LineEnd => moved || count == 1,
515        // `hor`.
516        Motion::Left
517        | Motion::Right
518        | Motion::SpaceFwd
519        | Motion::BackspaceBack
520        | Motion::WordFwd
521        | Motion::BigWordFwd
522        | Motion::WordBack
523        | Motion::BigWordBack
524        | Motion::WordEnd
525        | Motion::BigWordEnd
526        | Motion::Find { .. }
527        | Motion::FindRepeat { .. }
528        // `percent`.
529        | Motion::MatchBracket
530        // `block`.
531        | Motion::UnmatchedBracket { .. }
532        | Motion::ParagraphPrev
533        | Motion::ParagraphNext
534        | Motion::SentencePrev
535        | Motion::SentenceNext
536        | Motion::SectionBackward
537        | Motion::SectionForward
538        | Motion::SectionEndBackward
539        | Motion::SectionEndForward
540        // `search`.
541        | Motion::WordAtCursor { .. }
542        | Motion::SearchNext { .. } => moved,
543        Motion::Up
544        | Motion::Down
545        | Motion::ScreenUp
546        | Motion::ScreenDown
547        | Motion::ChangeWordEnd { .. }
548        | Motion::WordEndBack
549        | Motion::BigWordEndBack
550        | Motion::FileTop
551        | Motion::FileBottom
552        | Motion::ViewportTop
553        | Motion::ViewportMiddle
554        | Motion::ViewportBottom
555        | Motion::LastNonBlank
556        | Motion::LineMiddle
557        | Motion::ScreenLineMiddle
558        | Motion::FirstNonBlankNextLine
559        | Motion::FirstNonBlankPrevLine
560        | Motion::FirstNonBlankLine
561        | Motion::GotoColumn => false,
562    }
563}
564/// The `cursor_down(count - 1)` that vim's `nv_dollar` and `g_` run before
565/// moving along the line; false when it fails, which aborts the command.
566/// It fails only on the last line: a count that overshoots the buffer still
567/// succeeds, clamped (`5$` on three rows lands on row 2). Without this, `2$`
568/// on a one-line buffer moved to the line end and `2C` / `2D` emptied the
569/// line, where vim does nothing at all.
570fn count_lines_down<H: hjkl_engine::types::Host>(
571    ed: &mut Editor<hjkl_buffer::View, H>,
572    count: usize,
573) -> bool {
574    if count <= 1 {
575        return true;
576    }
577    if ed.cursor().0 >= last_content_row(ed) {
578        return false;
579    }
580    let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
581    let mut sticky = ed.sticky_col();
582    let tabstop = ed.settings().tabstop;
583    hjkl_engine::motions::move_down(ed.buffer_mut(), &folds, count - 1, &mut sticky, tabstop);
584    ed.set_sticky_col(sticky);
585    true
586}
587pub fn apply_motion_cursor<H: hjkl_engine::types::Host>(
588    ed: &mut Editor<hjkl_buffer::View, H>,
589    motion: &Motion,
590    count: usize,
591) {
592    apply_motion_cursor_ctx(ed, motion, count, false)
593}
594pub fn apply_motion_cursor_ctx<H: hjkl_engine::types::Host>(
595    ed: &mut Editor<hjkl_buffer::View, H>,
596    motion: &Motion,
597    count: usize,
598    as_operator: bool,
599) {
600    // Clamp the count where it fans out into the per-motion `0..count` walk.
601    // Two bounds:
602    //  - vim's documented ceiling (`:h count`) for folded counts; and
603    //  - the buffer's character count, since a motion can never make progress
604    //    past the end of the buffer — without this a pathological prefix
605    //    (`999999999w`, `<big>dw`) would spin the walk up to ~1e9 times,
606    //    freezing the UI, even though the result is identical to stopping at
607    //    the buffer edge.
608    let count = count
609        .min(MAX_COUNT)
610        .min(ed.buffer().rope().len_chars().saturating_add(1));
611    match motion {
612        Motion::Left => {
613            // `h` — View clamps at col 0 (no wrap), matching vim.
614            hjkl_engine::motions::move_left(ed.buffer_mut(), count);
615        }
616        Motion::Right => {
617            // `l` — operator-motion context (`dl`/`cl`/`yl`) is allowed
618            // one past the last char so the range includes it, and so is
619            // Visual mode (vim's `nv_right` `past_line`), where the NUL
620            // selects the line break; Normal mode clamps at the last char.
621            if as_operator || vim(ed).is_visual() {
622                hjkl_engine::motions::move_right_to_end(ed.buffer_mut(), count);
623            } else {
624                hjkl_engine::motions::move_right_in_line(ed.buffer_mut(), count);
625            }
626        }
627        Motion::SpaceFwd => {
628            // `<Space>` — wraps to next line at EOL in cursor context; mid-line
629            // char delete like `l` under an operator (`d<Space>`).
630            if as_operator {
631                hjkl_engine::motions::move_right_to_end(ed.buffer_mut(), count);
632            } else {
633                hjkl_engine::motions::move_space_fwd(ed.buffer_mut(), count);
634            }
635        }
636        Motion::BackspaceBack => {
637            // `<BS>` — wraps to prev line's last char at BOL in cursor context;
638            // mid-line char move like `h` under an operator (`d<BS>`).
639            if as_operator {
640                hjkl_engine::motions::move_left(ed.buffer_mut(), count);
641            } else {
642                hjkl_engine::motions::move_backspace_back(ed.buffer_mut(), count);
643            }
644        }
645        Motion::Up => {
646            // Final col is clamped by the `Move::Vertical` re-move in
647            // `execute_motion` below — push the post-move row to the
648            // textarea and let the vertical clamp finish the work.
649            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
650            let mut sticky = ed.sticky_col();
651            let tabstop = ed.settings().tabstop;
652            hjkl_engine::motions::move_up(ed.buffer_mut(), &folds, count, &mut sticky, tabstop);
653            ed.set_sticky_col(sticky);
654        }
655        Motion::Down => {
656            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
657            let mut sticky = ed.sticky_col();
658            let tabstop = ed.settings().tabstop;
659            hjkl_engine::motions::move_down(ed.buffer_mut(), &folds, count, &mut sticky, tabstop);
660            ed.set_sticky_col(sticky);
661        }
662        Motion::ScreenUp => {
663            let v = *ed.host().viewport();
664            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
665            let mut sticky = ed.sticky_col();
666            let tabstop = ed.settings().tabstop;
667            hjkl_engine::motions::move_screen_up(
668                ed.buffer_mut(),
669                &folds,
670                &v,
671                count,
672                &mut sticky,
673                tabstop,
674            );
675            ed.set_sticky_col(sticky);
676        }
677        Motion::ScreenDown => {
678            let v = *ed.host().viewport();
679            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
680            let mut sticky = ed.sticky_col();
681            let tabstop = ed.settings().tabstop;
682            hjkl_engine::motions::move_screen_down(
683                ed.buffer_mut(),
684                &folds,
685                &v,
686                count,
687                &mut sticky,
688                tabstop,
689            );
690            ed.set_sticky_col(sticky);
691        }
692        Motion::WordFwd | Motion::BigWordFwd => {
693            let big = matches!(motion, Motion::BigWordFwd);
694            let iskeyword = ed.settings().iskeyword.clone();
695            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
696            hjkl_engine::motions::move_word_fwd(ed.buffer_mut(), &folds, big, count, &iskeyword);
697            // Cursor context: clamp to the last char of the line so a counted
698            // `w` past EOF never lands past the last character.
699            if !as_operator {
700                let (row, col) = ed.cursor();
701                let line_len = buf_line_chars(ed.buffer(), row);
702                if col > line_len.saturating_sub(1) {
703                    ed.jump_cursor(row, line_len.saturating_sub(1));
704                }
705            }
706        }
707        Motion::WordBack | Motion::BigWordBack => {
708            let big = matches!(motion, Motion::BigWordBack);
709            let iskeyword = ed.settings().iskeyword.clone();
710            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
711            hjkl_engine::motions::move_word_back(ed.buffer_mut(), &folds, big, count, &iskeyword);
712        }
713        Motion::WordEnd | Motion::BigWordEnd | Motion::ChangeWordEnd { .. } => {
714            let (big, stop) = match motion {
715                Motion::BigWordEnd => (true, false),
716                Motion::ChangeWordEnd { big } => (*big, true),
717                _ => (false, false),
718            };
719            let iskeyword = ed.settings().iskeyword.clone();
720            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
721            hjkl_engine::motions::move_word_end(
722                ed.buffer_mut(),
723                &folds,
724                big,
725                count,
726                stop,
727                &iskeyword,
728            );
729        }
730        Motion::WordEndBack => {
731            let iskeyword = ed.settings().iskeyword.clone();
732            hjkl_engine::motions::move_word_end_back(ed.buffer_mut(), false, count, &iskeyword);
733        }
734        Motion::BigWordEndBack => {
735            let iskeyword = ed.settings().iskeyword.clone();
736            hjkl_engine::motions::move_word_end_back(ed.buffer_mut(), true, count, &iskeyword);
737        }
738        Motion::LineStart => {
739            hjkl_engine::motions::move_line_start(ed.buffer_mut());
740        }
741        Motion::FirstNonBlank => {
742            hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
743        }
744        Motion::LineEnd => {
745            // `[count]$`: move count-1 lines down, then to line end.
746            // Vim normal-mode `$` lands on the last char, not one past it;
747            // Visual mode lands on the NUL, so `v$` takes the line break.
748            if count_lines_down(ed, count) {
749                if vim(ed).is_visual() && !as_operator {
750                    let row = ed.cursor().0;
751                    let len = buf_line_chars(ed.buffer(), row);
752                    buf_set_cursor_rc(ed.buffer_mut(), row, len);
753                } else {
754                    hjkl_engine::motions::move_line_end(ed.buffer_mut());
755                }
756            }
757        }
758        Motion::FileTop => {
759            // `count gg` jumps to line `count` (first non-blank);
760            // bare `gg` lands at the top.
761            if count > 1 {
762                hjkl_engine::motions::move_bottom(ed.buffer_mut(), count);
763            } else {
764                hjkl_engine::motions::move_top(ed.buffer_mut());
765            }
766        }
767        Motion::FileBottom => {
768            // `count G` jumps to line `count`; bare `G` lands at
769            // the buffer bottom (`View::move_bottom(0)`).
770            if count > 1 {
771                hjkl_engine::motions::move_bottom(ed.buffer_mut(), count);
772            } else {
773                hjkl_engine::motions::move_bottom(ed.buffer_mut(), 0);
774            }
775        }
776        Motion::Find { ch, forward, till } => {
777            // Skip an adjacent target when this is a `;`/`,` repeat, and on the
778            // 2nd..Nth step of a counted `t`/`T` (the cursor lands one cell
779            // short each time, so a naive repeat would stick).
780            let repeat = std::mem::take(&mut vim_mut(ed).find_repeat_skip);
781            for i in 0..count {
782                let skip_adjacent = repeat || i > 0;
783                if !find_char_on_line(ed, *ch, *forward, *till, skip_adjacent) {
784                    break;
785                }
786            }
787        }
788        Motion::FindRepeat { .. } => {} // already resolved upstream
789        Motion::MatchBracket => {
790            let _ = matching_bracket(ed);
791        }
792        Motion::UnmatchedBracket { forward, open } => {
793            goto_unmatched_bracket(ed, *forward, *open, count);
794        }
795        Motion::WordAtCursor {
796            forward,
797            whole_word,
798        } => {
799            word_at_cursor_search(ed, *forward, *whole_word, count);
800        }
801        Motion::SearchNext { reverse } => {
802            // Re-push the last query so the buffer's search state is
803            // correct even if the host happened to clear it (e.g. while
804            // a Visual mode draw was in progress).
805            if let Some(pattern) = ed.last_search_pattern() {
806                ed.push_search_pattern(&pattern);
807            }
808            if ed.search_state().pattern.is_none() {
809                return;
810            }
811            // `n` repeats the last search in its committed direction;
812            // `N` inverts. So a `?` search makes `n` walk backward and
813            // `N` walk forward.
814            let forward = ed.last_search_forward() != *reverse;
815            for _ in 0..count.max(1) {
816                if forward {
817                    ed.search_advance_forward(true);
818                } else {
819                    ed.search_advance_backward(true);
820                }
821            }
822        }
823        Motion::ViewportTop => {
824            let v = *ed.host().viewport();
825            hjkl_engine::motions::move_viewport_top(ed.buffer_mut(), &v, count.saturating_sub(1));
826        }
827        Motion::ViewportMiddle => {
828            let v = *ed.host().viewport();
829            hjkl_engine::motions::move_viewport_middle(ed.buffer_mut(), &v);
830        }
831        Motion::ViewportBottom => {
832            let v = *ed.host().viewport();
833            hjkl_engine::motions::move_viewport_bottom(
834                ed.buffer_mut(),
835                &v,
836                count.saturating_sub(1),
837            );
838        }
839        Motion::LastNonBlank => {
840            // `[count]g_`: count-1 lines down, as `$`.
841            if count_lines_down(ed, count) {
842                hjkl_engine::motions::move_last_non_blank(ed.buffer_mut());
843            }
844        }
845        Motion::LineMiddle => {
846            let row = ed.cursor().0;
847            let line_chars = buf_line_chars(ed.buffer(), row);
848            // Vim's `gM`: column = floor(chars / 2). Empty / single-char
849            // lines stay at col 0.
850            let target = line_chars / 2;
851            ed.jump_cursor(row, target);
852        }
853        Motion::ScreenLineMiddle => {
854            // Vim's `gm`: middle of the *screen* line = column
855            // `viewport_width / 2`, clamped to the last char of the line.
856            let row = ed.cursor().0;
857            let width = ed.host().viewport().width as usize;
858            let last = buf_line_chars(ed.buffer(), row).saturating_sub(1);
859            let target = (width / 2).min(last);
860            ed.jump_cursor(row, target);
861        }
862        Motion::ParagraphPrev => {
863            hjkl_engine::motions::move_paragraph_prev(ed.buffer_mut(), count);
864        }
865        Motion::ParagraphNext => {
866            hjkl_engine::motions::move_paragraph_next(ed.buffer_mut(), count);
867        }
868        // `(` / `)` are all-or-nothing under a count: vim's `findsent` returns
869        // FAIL as soon as one repetition has nowhere left to go, and
870        // `nv_brace` then beeps with the cursor untouched. Moving as far as
871        // possible is what made `2)` on a single sentence-less line land on
872        // the last char instead of staying put.
873        Motion::SentencePrev => {
874            let start = ed.cursor();
875            for _ in 0..count.max(1) {
876                match sentence_boundary(ed, false) {
877                    Some((row, col)) => ed.jump_cursor(row, col),
878                    None => {
879                        ed.jump_cursor(start.0, start.1);
880                        break;
881                    }
882                }
883            }
884        }
885        Motion::SentenceNext => {
886            use crate::vim::text_object::SentenceStep;
887            let start = ed.cursor();
888            let total = count.max(1);
889            for i in 0..total {
890                match crate::vim::text_object::sentence_step_forward(ed) {
891                    SentenceStep::Boundary((row, col)) => ed.jump_cursor(row, col),
892                    // Already parked on the last cell: a no-op repetition,
893                    // not a failure (`9)` at end-of-buffer stays put).
894                    SentenceStep::AtEnd => {}
895                    SentenceStep::EndOfBuffer((row, col)) => {
896                        if i + 1 == total {
897                            ed.jump_cursor(row, col);
898                        } else {
899                            ed.jump_cursor(start.0, start.1);
900                            break;
901                        }
902                    }
903                }
904            }
905        }
906        Motion::SectionBackward => {
907            hjkl_engine::motions::move_section_backward(ed.buffer_mut(), count);
908        }
909        Motion::SectionForward => {
910            hjkl_engine::motions::move_section_forward(ed.buffer_mut(), count);
911        }
912        Motion::SectionEndBackward => {
913            hjkl_engine::motions::move_section_end_backward(ed.buffer_mut(), count);
914        }
915        Motion::SectionEndForward => {
916            hjkl_engine::motions::move_section_end_forward(ed.buffer_mut(), count);
917        }
918        Motion::FirstNonBlankNextLine => {
919            hjkl_engine::motions::move_first_non_blank_next_line(ed.buffer_mut(), count);
920        }
921        Motion::FirstNonBlankPrevLine => {
922            hjkl_engine::motions::move_first_non_blank_prev_line(ed.buffer_mut(), count);
923        }
924        Motion::FirstNonBlankLine => {
925            hjkl_engine::motions::move_first_non_blank_line(ed.buffer_mut(), count);
926        }
927        Motion::GotoColumn => {
928            hjkl_engine::motions::move_goto_column(ed.buffer_mut(), count);
929        }
930    }
931}
932pub fn move_first_non_whitespace<H: hjkl_engine::types::Host>(
933    ed: &mut Editor<hjkl_buffer::View, H>,
934) {
935    // Some call sites invoke this right after `dd` / `<<` / `>>` etc
936    // mutates the textarea content, so the migration buffer hasn't
937    // seen the new lines OR new cursor yet. Mirror the full content
938    // across before delegating, then push the result back so the
939    // textarea reflects the resolved column too.
940    ed.sync_buffer_content_from_textarea();
941    hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
942}
943/// Where `I` starts inserting: before the first non-blank, or — on a row that
944/// is all whitespace — after it. That is vim's `beginline(BL_WHITE)` without
945/// the `BL_FIX` that keeps `^` on the row's last char.
946pub fn move_to_insert_start<H: hjkl_engine::types::Host>(ed: &mut Editor<hjkl_buffer::View, H>) {
947    move_first_non_whitespace(ed);
948    let row = ed.cursor().0;
949    let line = buf_line(ed.buffer(), row).unwrap_or_default();
950    if !line.is_empty() && line.chars().all(char::is_whitespace) {
951        buf_set_cursor_rc(ed.buffer_mut(), row, line.chars().count());
952    }
953}
954pub fn find_char_on_line<H: hjkl_engine::types::Host>(
955    ed: &mut Editor<hjkl_buffer::View, H>,
956    ch: char,
957    forward: bool,
958    till: bool,
959    skip_adjacent: bool,
960) -> bool {
961    hjkl_engine::motions::find_char_on_line(ed.buffer_mut(), ch, forward, till, skip_adjacent)
962}
963pub fn matching_bracket<H: hjkl_engine::types::Host>(
964    ed: &mut Editor<hjkl_buffer::View, H>,
965) -> bool {
966    hjkl_engine::motions::match_bracket(ed.buffer_mut())
967}
968/// `[(` / `])` / `[{` / `]}` — move to the `count`-th previous (`forward =
969/// false`) / next (`forward = true`) unmatched bracket of the kind given by
970/// `open` (`(` or `{`). Balanced inner pairs are skipped via a depth counter.
971pub fn goto_unmatched_bracket<H: hjkl_engine::types::Host>(
972    ed: &mut Editor<hjkl_buffer::View, H>,
973    forward: bool,
974    open: char,
975    count: usize,
976) {
977    let close = match open {
978        '(' => ')',
979        '{' => '}',
980        _ => return,
981    };
982    let cursor = buf_cursor_pos(ed.buffer());
983    let rows = buf_row_count(ed.buffer());
984    let target = count.max(1);
985    let mut found = 0usize;
986    let mut depth = 0i32;
987
988    if forward {
989        let mut r = cursor.row;
990        let mut from_col = cursor.col + 1;
991        while r < rows {
992            let line: Vec<char> = buf_line(ed.buffer(), r)
993                .unwrap_or_default()
994                .chars()
995                .collect();
996            let mut ci = from_col;
997            while ci < line.len() {
998                let ch = line[ci];
999                if ch == open {
1000                    depth += 1;
1001                } else if ch == close {
1002                    if depth == 0 {
1003                        found += 1;
1004                        if found == target {
1005                            buf_set_cursor_rc(ed.buffer_mut(), r, ci);
1006                            return;
1007                        }
1008                    } else {
1009                        depth -= 1;
1010                    }
1011                }
1012                ci += 1;
1013            }
1014            r += 1;
1015            from_col = 0;
1016        }
1017    } else {
1018        let mut r = cursor.row as isize;
1019        // First row scans from the column left of the cursor; earlier rows from
1020        // their last column (`isize::MAX` clamps to `len - 1`).
1021        let mut from_col = cursor.col as isize - 1;
1022        while r >= 0 {
1023            let line: Vec<char> = buf_line(ed.buffer(), r as usize)
1024                .unwrap_or_default()
1025                .chars()
1026                .collect();
1027            let mut ci = from_col.min(line.len() as isize - 1);
1028            while ci >= 0 {
1029                let ch = line[ci as usize];
1030                if ch == close {
1031                    depth += 1;
1032                } else if ch == open {
1033                    if depth == 0 {
1034                        found += 1;
1035                        if found == target {
1036                            buf_set_cursor_rc(ed.buffer_mut(), r as usize, ci as usize);
1037                            return;
1038                        }
1039                    } else {
1040                        depth -= 1;
1041                    }
1042                }
1043                ci -= 1;
1044            }
1045            r -= 1;
1046            from_col = isize::MAX;
1047        }
1048    }
1049}
1050pub fn word_at_cursor_search<H: hjkl_engine::types::Host>(
1051    ed: &mut Editor<hjkl_buffer::View, H>,
1052    forward: bool,
1053    whole_word: bool,
1054    count: usize,
1055) {
1056    let (row, col) = ed.cursor();
1057    let line: String = buf_line(ed.buffer(), row).unwrap_or_default();
1058    let chars: Vec<char> = line.chars().collect();
1059    if chars.is_empty() {
1060        return;
1061    }
1062    // Expand around cursor to a word boundary.
1063    let spec = ed.settings().iskeyword.clone();
1064    let is_word = |c: char| is_keyword_char(c, &spec);
1065    let mut start = col.min(chars.len().saturating_sub(1));
1066    if is_word(chars[start]) {
1067        while start > 0 && is_word(chars[start - 1]) {
1068            start -= 1;
1069        }
1070    } else {
1071        // B19: the cursor isn't on a keyword char. `:h star` — both `*`
1072        // and `#` advance FORWARD to the next keyword char on this line
1073        // before extracting the word; only the SEARCH that follows differs
1074        // by direction. Anchor the actual cursor at the found word's start
1075        // so the subsequent search-advance call's skip-current logic
1076        // treats it as the current match and steps to the true next (or,
1077        // for `#`, previous) occurrence — matching nvim, which lands on
1078        // the *second* occurrence from punctuation, not the nearest one.
1079        while start < chars.len() && !is_word(chars[start]) {
1080            start += 1;
1081        }
1082        if start >= chars.len() {
1083            return;
1084        }
1085        buf_set_cursor_rc(ed.buffer_mut(), row, start);
1086    }
1087    let mut end = start;
1088    while end < chars.len() && is_word(chars[end]) {
1089        end += 1;
1090    }
1091    if end <= start {
1092        return;
1093    }
1094    let word: String = chars[start..end].iter().collect();
1095    let escaped = regex_escape(&word);
1096    let pattern = if whole_word {
1097        format!(r"\b{escaped}\b")
1098    } else {
1099        escaped
1100    };
1101    search_pattern_and_advance(ed, pattern, forward, count);
1102}
1103
1104pub fn search_selected_text<H: hjkl_engine::types::Host>(
1105    ed: &mut Editor<hjkl_buffer::View, H>,
1106    selected: &str,
1107    forward: bool,
1108    count: usize,
1109) -> bool {
1110    if selected.is_empty() {
1111        return false;
1112    }
1113    let selected = selected.replace('\\', r"\\").replace('\n', r"\n");
1114    let pattern = format!(r"\V{selected}");
1115    let (installed, found) = search_pattern_and_advance(ed, pattern.clone(), forward, count);
1116    if installed {
1117        ed.record_search_history(&pattern);
1118    }
1119    found
1120}
1121
1122fn search_pattern_and_advance<H: hjkl_engine::types::Host>(
1123    ed: &mut Editor<hjkl_buffer::View, H>,
1124    pattern: String,
1125    forward: bool,
1126    count: usize,
1127) -> (bool, bool) {
1128    ed.push_search_pattern(&pattern);
1129    if ed.search_state().pattern.is_none() {
1130        return (false, false);
1131    }
1132    // Remember the query so `n` / `N` keep working after the jump.
1133    ed.set_last_search_pattern_only(Some(pattern));
1134    ed.set_last_search_forward_only(forward);
1135    let mut found = false;
1136    for _ in 0..count.max(1) {
1137        found |= if forward {
1138            ed.search_advance_forward(true)
1139        } else {
1140            ed.search_advance_backward(true)
1141        };
1142    }
1143    (true, found)
1144}
1145pub fn regex_escape(s: &str) -> String {
1146    let mut out = String::with_capacity(s.len());
1147    for c in s.chars() {
1148        if matches!(
1149            c,
1150            '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\'
1151        ) {
1152            out.push('\\');
1153        }
1154        out.push(c);
1155    }
1156    out
1157}