Skip to main content

lattice_grammar/
builtins.rs

1//! Built-in motions, text objects, and operators that ship native (off the
2//! WASM boundary; per DESIGN.md §5.5.2 "built-ins stay native").
3//!
4//! This is the vim catalog: `w` / `b` / `e`, `f` / `t` and their repeats,
5//! `{` / `}`, `gg` / `G`, `%`, `n` / `*`, marks, `H` / `M` / `L`, `gj` /
6//! `gk`; the operators `d` / `c` / `y` / `<` / `>` / `=` / `gq` / `gU` /
7//! `gu` / `g~` / `zf` / `r`; and the `i` / `a` text objects. [`populate`]
8//! registers them all into a [`CommandRegistry`] under namespaced names
9//! (`motion:word-forward`, `operator:delete`, `text-object:inner-word`) and
10//! returns their ids as [`Builtins`].
11//!
12//! Every builtin is an ordinary registry entry: the dispatcher, `:describe-*`
13//! and plugins see it exactly as they see a plugin-contributed motion.
14//! Adding one is a registration here and a keymap binding; no dispatcher
15//! wiring. Evaluators never touch host state directly: what a motion needs
16//! from the host (folds, marks, viewport, last search) arrives through the
17//! resolver traits on [`GrammarEnv`](crate::GrammarEnv), and what an operator
18//! does is returned as an [`Effect`].
19//!
20//! Tree-sitter structural objects (`af` / `ac` / …) are registered by
21//! `lattice-syntax`, not here: they need a parse this crate cannot see, and
22//! reach it only through [`ScopeResolver`](crate::ScopeResolver).
23
24use lattice_protocol::edit::Edit;
25use lattice_protocol::position::{Position, Range as ProtoRange};
26use std::sync::Arc;
27
28use crate::cancel::CheckCancelled;
29use crate::effect::{Effect, YankKind};
30use crate::error::CommandError;
31use crate::registry::{
32    CommandRegistry, FindKind, MotionContext, MotionId, MotionResult, MotionSpec, OperatorContext,
33    OperatorId, OperatorSpec, TextObjectContext, TextObjectId, TextObjectSpec,
34};
35
36/// Register every native built-in motion, operator and text object into
37/// `registry`, and return their ids.
38///
39/// Call once per registry, at boot. Each call mints fresh ids, so calling
40/// it twice re-registers every name onto new ids (the name index then
41/// points at the second set). Also tags `w` / `W` as word-forward motions
42/// (see [`CommandRegistry::tag_word_forward_motion`]).
43///
44/// # Examples
45///
46/// `dw` on `hello world`: the delete operator with a word-forward target.
47///
48/// ```
49/// use lattice_core::{BufferId, Document};
50/// use lattice_grammar::{
51///     Args, CancellationToken, CommandInvocation, CommandRegistry, Effect, Target, builtins,
52///     execute,
53/// };
54/// use lattice_protocol::position::Position;
55///
56/// let mut registry = CommandRegistry::new();
57/// let b = builtins::populate(&mut registry);
58/// assert_eq!(registry.id_by_name("motion:word-forward"), Some(b.word_forward.0));
59///
60/// let mut doc = Document::from_text("hello world");
61/// let dw = CommandInvocation::of(b.delete.0).with_target(Target::Motion(b.word_forward, Args::None));
62/// let effect = execute(
63///     &registry,
64///     &mut doc,
65///     BufferId(0),
66///     Position::ZERO,
67///     dw,
68///     &CancellationToken::never(),
69/// )
70/// .unwrap();
71///
72/// // The operator edited the document in place and reports the edit plus
73/// // the yank into the unnamed register.
74/// assert_eq!(doc.text().to_string(), "world");
75/// assert!(matches!(effect, Effect::Many(ref parts) if parts.len() == 2));
76/// ```
77pub fn populate(registry: &mut CommandRegistry) -> Builtins {
78    let word_forward = registry.register_motion(
79        "motion:word-forward",
80        "Move to the start of the next word.",
81        MotionSpec {
82            curswant: crate::registry::CurswantEffect::default(),
83            jump: false,
84            exclusive: true,
85            apply: Arc::new(motion_word_forward),
86            args_schema: vec![],
87        },
88    );
89    let word_backward = registry.register_motion(
90        "motion:word-backward",
91        "Move to the start of the previous word (vim's `b`).",
92        MotionSpec {
93            curswant: crate::registry::CurswantEffect::default(),
94            jump: false,
95            exclusive: true,
96            apply: Arc::new(motion_word_backward),
97            args_schema: vec![],
98        },
99    );
100    let word_end = registry.register_motion(
101        "motion:word-end",
102        "Move to the last byte of the current or next word (vim's `e`). Inclusive.",
103        MotionSpec {
104            curswant: crate::registry::CurswantEffect::default(),
105            jump: false,
106            exclusive: false,
107            apply: Arc::new(motion_word_end),
108            args_schema: vec![],
109        },
110    );
111    let first_non_blank = registry.register_motion(
112        "motion:first-non-blank",
113        "Move to the first non-whitespace byte of the current line (vim's `^`).",
114        MotionSpec {
115            curswant: crate::registry::CurswantEffect::default(),
116            jump: false,
117            // `^` is exclusive in vim. (Was mis-registered inclusive while
118            // the dispatcher ignored the flag; now honoured, so it must be
119            // correct.)
120            exclusive: true,
121            apply: Arc::new(motion_first_non_blank),
122            args_schema: vec![],
123        },
124    );
125    let find_char_forward = registry.register_motion(
126        "motion:find-char-forward",
127        "Move to the next occurrence of `args.char` on the current line (vim's `f`).",
128        MotionSpec {
129            curswant: crate::registry::CurswantEffect::default(),
130            jump: false,
131            exclusive: false,
132            apply: Arc::new(motion_find_char_forward),
133            args_schema: vec![],
134        },
135    );
136    let find_char_backward = registry.register_motion(
137        "motion:find-char-backward",
138        "Move to the previous occurrence of `args.char` on the current line (vim's `F`).",
139        MotionSpec {
140            curswant: crate::registry::CurswantEffect::default(),
141            jump: false,
142            // EXCLUSIVE, which is what vim says (`:h F`) and what `dF` has
143            // always DONE here. It was registered inclusive, and the
144            // backward-inclusive branch of `motion_to_range` happened to
145            // produce the exclusive range anyway — two errors cancelling.
146            // VM.3b fixed that branch, so this has to say what it means.
147            exclusive: true,
148            apply: Arc::new(motion_find_char_backward),
149            args_schema: vec![],
150        },
151    );
152    let till_char_forward = registry.register_motion(
153        "motion:till-char-forward",
154        "Move to one byte before the next occurrence of `args.char` (vim's `t`).",
155        MotionSpec {
156            curswant: crate::registry::CurswantEffect::default(),
157            jump: false,
158            exclusive: false,
159            apply: Arc::new(motion_till_char_forward),
160            args_schema: vec![],
161        },
162    );
163    let till_char_backward = registry.register_motion(
164        "motion:till-char-backward",
165        "Move to one byte after the previous occurrence of `args.char` (vim's `T`).",
166        MotionSpec {
167            curswant: crate::registry::CurswantEffect::default(),
168            jump: false,
169            // Exclusive, per `:h T` — see `find_char_backward` above.
170            exclusive: true,
171            apply: Arc::new(motion_till_char_backward),
172            args_schema: vec![],
173        },
174    );
175    let big_word_forward = registry.register_motion(
176        "motion:big-word-forward",
177        "Move to the start of the next WORD (vim's `W` -- whitespace-delimited).",
178        MotionSpec {
179            curswant: crate::registry::CurswantEffect::default(),
180            jump: false,
181            exclusive: true,
182            apply: Arc::new(motion_big_word_forward),
183            args_schema: vec![],
184        },
185    );
186    // Vim word-motion special case (`:help word-motions`): under an
187    // operator, `dw` / `cw` / `yw` on the last word of a line stop at the
188    // line end instead of reaching into the next line's first word. Tag
189    // both word-forward motions so the operator range resolver applies it.
190    registry.tag_word_forward_motion(word_forward);
191    registry.tag_word_forward_motion(big_word_forward);
192
193    let big_word_backward = registry.register_motion(
194        "motion:big-word-backward",
195        "Move to the start of the previous WORD (vim's `B`).",
196        MotionSpec {
197            curswant: crate::registry::CurswantEffect::default(),
198            jump: false,
199            exclusive: true,
200            apply: Arc::new(motion_big_word_backward),
201            args_schema: vec![],
202        },
203    );
204    let big_word_end = registry.register_motion(
205        "motion:big-word-end",
206        "Move to the last byte of the current or next WORD (vim's `E`).",
207        MotionSpec {
208            curswant: crate::registry::CurswantEffect::default(),
209            jump: false,
210            exclusive: false,
211            apply: Arc::new(motion_big_word_end),
212            args_schema: vec![],
213        },
214    );
215    let paragraph_forward = registry.register_motion(
216        "motion:paragraph-forward",
217        "Move to the next paragraph boundary -- the next blank line at or after the cursor (vim's `}`).",
218        MotionSpec {
219            curswant: crate::registry::CurswantEffect::default(),
220            jump: true,
221            // `}` is exclusive in vim (`d}` does not include the blank line).
222            exclusive: true,
223            apply: Arc::new(motion_paragraph_forward),
224            args_schema: vec![],
225        },
226    );
227    let paragraph_backward = registry.register_motion(
228        "motion:paragraph-backward",
229        "Move to the previous paragraph boundary (vim's `{`).",
230        MotionSpec {
231            curswant: crate::registry::CurswantEffect::default(),
232            jump: true,
233            // `{` is exclusive in vim.
234            exclusive: true,
235            apply: Arc::new(motion_paragraph_backward),
236            args_schema: vec![],
237        },
238    );
239    let sentence_forward = registry.register_motion(
240        "motion:sentence-forward",
241        "Move to the start of the next sentence (vim's `)`).",
242        MotionSpec {
243            curswant: crate::registry::CurswantEffect::default(),
244            jump: true,
245            // `)` is exclusive in vim.
246            exclusive: true,
247            apply: Arc::new(motion_sentence_forward),
248            args_schema: vec![],
249        },
250    );
251    let sentence_backward = registry.register_motion(
252        "motion:sentence-backward",
253        "Move to the start of the previous sentence (vim's `(`).",
254        MotionSpec {
255            curswant: crate::registry::CurswantEffect::default(),
256            jump: true,
257            // `(` is exclusive in vim.
258            exclusive: true,
259            apply: Arc::new(motion_sentence_backward),
260            args_schema: vec![],
261        },
262    );
263    let char_left = registry.register_motion(
264        "motion:char-left",
265        "Move one byte to the left within the current line.",
266        MotionSpec {
267            curswant: crate::registry::CurswantEffect::default(),
268            jump: false,
269            // `h` is exclusive in vim. (Backward motion, so behaviour is
270            // unchanged either way, but the registration should be correct.)
271            exclusive: true,
272            apply: Arc::new(motion_char_left),
273            args_schema: vec![],
274        },
275    );
276    let char_right = registry.register_motion(
277        "motion:char-right",
278        "Move one byte to the right within the current line.",
279        MotionSpec {
280            curswant: crate::registry::CurswantEffect::default(),
281            jump: false,
282            exclusive: true,
283            apply: Arc::new(motion_char_right),
284            args_schema: vec![],
285        },
286    );
287    let line_up = registry.register_motion(
288        "motion:line-up",
289        "Move one line up.",
290        MotionSpec {
291            curswant: crate::registry::CurswantEffect::Keep,
292            jump: false,
293            // `k` only ever travels backward, so exclusive here reproduces
294            // exactly the range `dk` / `d<C-u>` / `d<PageUp>` produced before
295            // VM.3b fixed the backward-inclusive branch.
296            //
297            // The honest answer is that `k` is LINEWISE in vim and `dk` should
298            // take both whole lines. The engine has no linewise-motion-target
299            // expansion yet (see `motion_rows`' note), and `MotionSpec` has no
300            // flag for it — `linewise` is decided per RESULT. Until that gap
301            // closes, `exclusive` is the only knob and this is the value that
302            // does not silently change what `dk` deletes.
303            exclusive: true,
304            apply: Arc::new(motion_line_up),
305            args_schema: vec![],
306        },
307    );
308    let line_down = registry.register_motion(
309        "motion:line-down",
310        "Move one line down.",
311        MotionSpec {
312            curswant: crate::registry::CurswantEffect::Keep,
313            jump: false,
314            exclusive: false,
315            apply: Arc::new(motion_line_down),
316            args_schema: vec![],
317        },
318    );
319    let line_start = registry.register_motion(
320        "motion:line-start",
321        "Move to the first byte of the current line.",
322        MotionSpec {
323            curswant: crate::registry::CurswantEffect::default(),
324            jump: false,
325            // `0` is exclusive in vim.
326            exclusive: true,
327            apply: Arc::new(motion_line_start),
328            args_schema: vec![],
329        },
330    );
331    let line_end = registry.register_motion(
332        "motion:line-end",
333        "Move to the last byte of the current line.",
334        MotionSpec {
335            curswant: crate::registry::CurswantEffect::PinToEnd,
336            jump: false,
337            exclusive: false,
338            apply: Arc::new(motion_line_end),
339            args_schema: vec![],
340        },
341    );
342    let goto_first_line = registry.register_motion(
343        "motion:goto-first-line",
344        "Jump to the first line of the buffer (vim's `gg`).",
345        MotionSpec {
346            curswant: crate::registry::CurswantEffect::default(),
347            jump: true,
348            exclusive: false,
349            apply: Arc::new(motion_goto_first_line),
350            args_schema: vec![],
351        },
352    );
353    let goto_last_line = registry.register_motion(
354        "motion:goto-last-line",
355        "Jump to the last line of the buffer (vim's `G`).",
356        MotionSpec {
357            curswant: crate::registry::CurswantEffect::default(),
358            jump: true,
359            exclusive: false,
360            apply: Arc::new(motion_goto_last_line),
361            args_schema: vec![],
362        },
363    );
364
365    let delete = registry.register_operator(
366        "operator:delete",
367        "Delete the bytes covered by the target range; yank to the unnamed register.",
368        OperatorSpec {
369            repeatable: true,
370            apply: Arc::new(operator_delete),
371            args_schema: vec![],
372            blockwise_per_row: true,
373            post_motion_char: false,
374        },
375    );
376    let change = registry.register_operator(
377        "operator:change",
378        "Delete the bytes covered by the target range and enter Insert mode (vim's `c`).",
379        OperatorSpec {
380            repeatable: true,
381            apply: Arc::new(operator_change),
382            args_schema: vec![],
383            blockwise_per_row: true,
384            post_motion_char: false,
385        },
386    );
387    let yank = registry.register_operator(
388        "operator:yank",
389        "Copy the bytes covered by the target range into the named register without modifying the buffer (vim's `y`).",
390        OperatorSpec {
391            repeatable: true,
392            apply: Arc::new(operator_yank),
393            args_schema: vec![],
394            blockwise_per_row: true,
395            post_motion_char: false,
396        },
397    );
398    let indent_left = registry.register_operator(
399        "operator:indent-left",
400        "Strip leading indentation (4 spaces or one tab) from each line in the range (vim's `<`).",
401        OperatorSpec {
402            repeatable: true,
403            apply: Arc::new(operator_indent_left),
404            args_schema: vec![],
405            // Linewise effect -- one batched edit covers every line
406            // in the visual span, regardless of charwise / blockwise.
407            blockwise_per_row: false,
408            post_motion_char: false,
409        },
410    );
411    let reindent = registry.register_operator(
412        "operator:reindent",
413        "Reindent each line in the range to the depth the syntax tree implies, \
414         adjusting leading whitespace only (vim's `=`).",
415        OperatorSpec {
416            repeatable: true,
417            apply: Arc::new(operator_reindent),
418            args_schema: vec![],
419            // Linewise: a blockwise visual collapses to one contiguous
420            // range, matching `>` / `<`. Reindenting a rectangle makes
421            // no sense -- indentation is a property of whole lines.
422            blockwise_per_row: false,
423            post_motion_char: false,
424        },
425    );
426    let reformat = registry.register_operator(
427        "operator:reformat",
428        "Run the buffer's `format.reformat` chain (language server, or an external \
429         formatter) over the range — the operator form of `:format` (`g=`).",
430        OperatorSpec {
431            repeatable: true,
432            apply: Arc::new(operator_reformat),
433            args_schema: vec![],
434            // Linewise, like `=` and `gq`: every formatter this can
435            // reach works in whole lines.
436            blockwise_per_row: false,
437            post_motion_char: false,
438        },
439    );
440    let create_fold = registry.register_operator(
441        "operator:create-fold",
442        "Create a closed fold over the whole lines the {motion} or selection \
443         covers (vim's `zf`).",
444        OperatorSpec {
445            // Vim's `.` doesn't repeat fold creation.
446            repeatable: false,
447            apply: Arc::new(operator_create_fold),
448            args_schema: vec![],
449            // A fold is whole lines, so a blockwise selection is one range.
450            blockwise_per_row: false,
451            post_motion_char: false,
452        },
453    );
454    let reflow = registry.register_operator(
455        "operator:reflow",
456        "Reflow each paragraph in the range to `textwidth`, keeping indentation \
457         and any comment leader (vim's `gq` and `gw`, which are one operator here).",
458        OperatorSpec {
459            repeatable: true,
460            apply: Arc::new(operator_reflow),
461            args_schema: vec![],
462            // Linewise, like `=`: filling is a property of whole lines,
463            // and a blockwise visual collapses to one contiguous range
464            // rather than reflowing a rectangle.
465            blockwise_per_row: false,
466            post_motion_char: false,
467        },
468    );
469    let indent_right = registry.register_operator(
470        "operator:indent-right",
471        "Prepend 4 spaces to each line in the range (vim's `>`).",
472        OperatorSpec {
473            repeatable: true,
474            apply: Arc::new(operator_indent_right),
475            args_schema: vec![],
476            blockwise_per_row: false,
477            post_motion_char: false,
478        },
479    );
480    let upper = registry.register_operator(
481        "operator:upper",
482        "Uppercase ASCII letters in the range (vim's `gU`).",
483        OperatorSpec {
484            repeatable: true,
485            apply: Arc::new(operator_upper),
486            args_schema: vec![],
487            blockwise_per_row: false,
488            post_motion_char: false,
489        },
490    );
491    let lower = registry.register_operator(
492        "operator:lower",
493        "Lowercase ASCII letters in the range (vim's `gu`).",
494        OperatorSpec {
495            repeatable: true,
496            apply: Arc::new(operator_lower),
497            args_schema: vec![],
498            blockwise_per_row: false,
499            post_motion_char: false,
500        },
501    );
502    let toggle_case = registry.register_operator(
503        "operator:toggle-case",
504        "Toggle case of ASCII letters in the range (vim's `g~`).",
505        OperatorSpec {
506            repeatable: true,
507            apply: Arc::new(operator_toggle_case),
508            args_schema: vec![],
509            blockwise_per_row: false,
510            post_motion_char: false,
511        },
512    );
513    let search = registry.register_operator(
514        "operator:search",
515        "Project-search for the text spanned by the motion / text object (Lattice's `g/`).",
516        OperatorSpec {
517            repeatable: false,
518            apply: Arc::new(operator_search),
519            args_schema: vec![],
520            blockwise_per_row: false,
521            post_motion_char: false,
522        },
523    );
524    let replace_char = registry.register_operator(
525        "operator:replace-char",
526        "Overwrite each non-newline char in the range with the captured char (vim's `r{char}` and Visual `r`).",
527        OperatorSpec {
528            repeatable: true,
529            apply: Arc::new(operator_replace_char),
530            // Args::Char(replacement) is folded in by the keymap's
531            // wildcard capture (same shape as `f{char}`, which also
532            // leaves its schema empty).
533            args_schema: vec![],
534            // Blockwise visual `r` overwrites each row's column slice
535            // independently, exactly like `d` / `y` / `c`.
536            blockwise_per_row: true,
537            post_motion_char: false,
538        },
539    );
540
541    let inner_sentence = registry.register_text_object(
542        "text-object:inner-sentence",
543        "Inner sentence -- text up to the next .!? that ends a sentence (vim's `is`).",
544        TextObjectSpec {
545            apply: Arc::new(text_object_inner_sentence),
546            args_schema: vec![],
547        },
548    );
549    let around_sentence = registry.register_text_object(
550        "text-object:around-sentence",
551        "Around sentence -- inner_sentence plus trailing whitespace (vim's `as`).",
552        TextObjectSpec {
553            apply: Arc::new(text_object_around_sentence),
554            args_schema: vec![],
555        },
556    );
557
558    let inner_paragraph = registry.register_text_object(
559        "text-object:inner-paragraph",
560        "Inner paragraph -- the run of non-blank lines containing the cursor (vim's `ip`).",
561        TextObjectSpec {
562            apply: Arc::new(text_object_inner_paragraph),
563            args_schema: vec![],
564        },
565    );
566    let around_paragraph = registry.register_text_object(
567        "text-object:around-paragraph",
568        "Around paragraph -- inner_paragraph plus trailing blank lines (vim's `ap`).",
569        TextObjectSpec {
570            apply: Arc::new(text_object_around_paragraph),
571            args_schema: vec![],
572        },
573    );
574
575    let inner_word = registry.register_text_object(
576        "text-object:inner-word",
577        "Inner word -- alphanum + underscore run containing the cursor (vim's `iw`).",
578        TextObjectSpec {
579            apply: Arc::new(text_object_inner_word),
580            args_schema: vec![],
581        },
582    );
583    let around_word = registry.register_text_object(
584        "text-object:around-word",
585        "Around word -- inner_word plus trailing whitespace (vim's `aw`).",
586        TextObjectSpec {
587            apply: Arc::new(text_object_around_word),
588            args_schema: vec![],
589        },
590    );
591    let inner_quote_double = registry.register_text_object(
592        "text-object:inner-quote-double",
593        "Inner double-quoted string -- text between the surrounding `\"` chars (vim's `i\"`).",
594        TextObjectSpec {
595            apply: Arc::new(|ctx| text_object_inner_quote(ctx, '"')),
596            args_schema: vec![],
597        },
598    );
599    let around_quote_double = registry.register_text_object(
600        "text-object:around-quote-double",
601        "Around double-quoted string -- includes the surrounding `\"` chars (vim's `a\"`).",
602        TextObjectSpec {
603            apply: Arc::new(|ctx| text_object_around_quote(ctx, '"')),
604            args_schema: vec![],
605        },
606    );
607    let inner_quote_single = registry.register_text_object(
608        "text-object:inner-quote-single",
609        "Inner single-quoted string (vim's `i'`).",
610        TextObjectSpec {
611            apply: Arc::new(|ctx| text_object_inner_quote(ctx, '\'')),
612            args_schema: vec![],
613        },
614    );
615    let around_quote_single = registry.register_text_object(
616        "text-object:around-quote-single",
617        "Around single-quoted string (vim's `a'`).",
618        TextObjectSpec {
619            apply: Arc::new(|ctx| text_object_around_quote(ctx, '\'')),
620            args_schema: vec![],
621        },
622    );
623    let inner_quote_backtick = registry.register_text_object(
624        "text-object:inner-quote-backtick",
625        "Inner backtick-quoted string (vim's ``i` ``).",
626        TextObjectSpec {
627            apply: Arc::new(|ctx| text_object_inner_quote(ctx, '`')),
628            args_schema: vec![],
629        },
630    );
631    let around_quote_backtick = registry.register_text_object(
632        "text-object:around-quote-backtick",
633        "Around backtick-quoted string (vim's ``a` ``).",
634        TextObjectSpec {
635            apply: Arc::new(|ctx| text_object_around_quote(ctx, '`')),
636            args_schema: vec![],
637        },
638    );
639    let inner_paren = registry.register_text_object(
640        "text-object:inner-paren",
641        "Inside the innermost enclosing `()` pair (vim's `i(`).",
642        TextObjectSpec {
643            apply: Arc::new(|ctx| text_object_inner_brackets(ctx, '(', ')')),
644            args_schema: vec![],
645        },
646    );
647    let around_paren = registry.register_text_object(
648        "text-object:around-paren",
649        "Around the innermost enclosing `()` pair, including the brackets (vim's `a(`).",
650        TextObjectSpec {
651            apply: Arc::new(|ctx| text_object_around_brackets(ctx, '(', ')')),
652            args_schema: vec![],
653        },
654    );
655    let inner_bracket = registry.register_text_object(
656        "text-object:inner-bracket",
657        "Inside the innermost enclosing `[]` pair (vim's `i[`).",
658        TextObjectSpec {
659            apply: Arc::new(|ctx| text_object_inner_brackets(ctx, '[', ']')),
660            args_schema: vec![],
661        },
662    );
663    let around_bracket = registry.register_text_object(
664        "text-object:around-bracket",
665        "Around the innermost enclosing `[]` pair, including the brackets (vim's `a[`).",
666        TextObjectSpec {
667            apply: Arc::new(|ctx| text_object_around_brackets(ctx, '[', ']')),
668            args_schema: vec![],
669        },
670    );
671    let inner_tag = registry.register_text_object(
672        "text-object:inner-tag",
673        "Inside the innermost enclosing XML/HTML tag pair (vim's `it`).",
674        TextObjectSpec {
675            apply: Arc::new(text_object_inner_tag),
676            args_schema: vec![],
677        },
678    );
679    let around_tag = registry.register_text_object(
680        "text-object:around-tag",
681        "Around the innermost enclosing XML/HTML tag pair, including the tags (vim's `at`).",
682        TextObjectSpec {
683            apply: Arc::new(text_object_around_tag),
684            args_schema: vec![],
685        },
686    );
687
688    let inner_brace = registry.register_text_object(
689        "text-object:inner-brace",
690        "Inside the innermost enclosing `{}` pair (vim's `i{`).",
691        TextObjectSpec {
692            apply: Arc::new(|ctx| text_object_inner_brackets(ctx, '{', '}')),
693            args_schema: vec![],
694        },
695    );
696    let around_brace = registry.register_text_object(
697        "text-object:around-brace",
698        "Around the innermost enclosing `{}` pair, including the brackets (vim's `a{`).",
699        TextObjectSpec {
700            apply: Arc::new(|ctx| text_object_around_brackets(ctx, '{', '}')),
701            args_schema: vec![],
702        },
703    );
704    let inner_big_word = registry.register_text_object(
705        "text-object:inner-big-word",
706        "Inner WORD -- whitespace-delimited run containing the cursor (vim's `iW`).",
707        TextObjectSpec {
708            apply: Arc::new(text_object_inner_big_word),
709            args_schema: vec![],
710        },
711    );
712    let around_big_word = registry.register_text_object(
713        "text-object:around-big-word",
714        "Around WORD -- inner WORD plus trailing whitespace (vim's `aW`).",
715        TextObjectSpec {
716            apply: Arc::new(text_object_around_big_word),
717            args_schema: vec![],
718        },
719    );
720    let inner_angle = registry.register_text_object(
721        "text-object:inner-angle",
722        "Inside the innermost enclosing `<>` pair (vim's `i<`).",
723        TextObjectSpec {
724            apply: Arc::new(|ctx| text_object_inner_brackets(ctx, '<', '>')),
725            args_schema: vec![],
726        },
727    );
728    let around_angle = registry.register_text_object(
729        "text-object:around-angle",
730        "Around the innermost enclosing `<>` pair, including the brackets (vim's `a<`).",
731        TextObjectSpec {
732            apply: Arc::new(|ctx| text_object_around_brackets(ctx, '<', '>')),
733            args_schema: vec![],
734        },
735    );
736
737    // N.1.6: comment text objects (commentstring-driven; the leader is
738    // injected per-buffer via `TextObjectContext.comment_syntax`).
739    let inner_comment = registry.register_text_object(
740        "text-object:inner-comment",
741        "Inner comment -- the comment text with the first line's leader stripped (`iC`).",
742        TextObjectSpec {
743            apply: Arc::new(text_object_inner_comment),
744            args_schema: vec![],
745        },
746    );
747    let around_comment = registry.register_text_object(
748        "text-object:around-comment",
749        "A comment -- the contiguous run of comment lines including the markers (`aC`).",
750        TextObjectSpec {
751            apply: Arc::new(text_object_around_comment),
752            args_schema: vec![],
753        },
754    );
755
756    let find_repeat = registry.register_motion(
757        "motion:find-repeat",
758        "Repeat the last `f` / `F` / `t` / `T` in the same direction (vim's `;`).",
759        MotionSpec {
760            curswant: crate::registry::CurswantEffect::default(),
761            // Not a jump: vim does not put `;` on the jump list, and it
762            // usually moves only a few columns.
763            jump: false,
764            // Never read: `motion_find_repeat` always sets
765            // `MotionResult::exclusive`, because the honest answer depends on
766            // which of `f` / `F` / `t` / `T` is being repeated. Kept
767            // `false` so `:describe-command` shows the common case (`;` after
768            // an `f`) rather than a coin-flip.
769            exclusive: false,
770            apply: Arc::new(motion_find_repeat),
771            args_schema: vec![],
772        },
773    );
774    let find_repeat_reverse = registry.register_motion(
775        "motion:find-repeat-reverse",
776        "Repeat the last `f` / `F` / `t` / `T` in the opposite direction (vim's `,`).",
777        MotionSpec {
778            curswant: crate::registry::CurswantEffect::default(),
779            jump: false,
780            exclusive: false,
781            apply: Arc::new(motion_find_repeat_reverse),
782            args_schema: vec![],
783        },
784    );
785
786    let match_pair = registry.register_motion(
787        "motion:match-pair",
788        "Move to the bracket matching the first one at or after the cursor on this line (vim's `%`).",
789        MotionSpec {
790            curswant: crate::registry::CurswantEffect::default(),
791            // A jump: vim records `%` on the jump list and opens a fold at the
792            // far end (`foldopen` ships with `percent`).
793            jump: true,
794            // INCLUSIVE. `d%` deletes both brackets and everything between —
795            // the defining behaviour, and the reason `exclusive: false` here is
796            // load-bearing rather than a default.
797            exclusive: false,
798            apply: Arc::new(motion_match_pair),
799            args_schema: vec![],
800        },
801    );
802    // VM.3i: vim's `zj` / `zk`. Charwise and exclusive (vim 9.2: `dzk` from
803    // 12,3 deletes "line 10\nline 11\nli"), not jumps, and "can be used after
804    // an operator". They were host actions, so `dzj` and `vzj` were unbound.
805    let goto_next_fold = registry.register_motion(
806        "motion:goto-next-fold",
807        "Move to the start of the next fold; a closed fold counts as one (vim's `zj`).",
808        MotionSpec {
809            curswant: crate::registry::CurswantEffect::default(),
810            jump: false,
811            exclusive: true,
812            apply: Arc::new(motion_goto_next_fold),
813            args_schema: vec![],
814        },
815    );
816    let goto_prev_fold = registry.register_motion(
817        "motion:goto-prev-fold",
818        "Move to the end of the previous fold; a closed fold counts as one (vim's `zk`).",
819        MotionSpec {
820            curswant: crate::registry::CurswantEffect::default(),
821            jump: false,
822            exclusive: true,
823            apply: Arc::new(motion_goto_prev_fold),
824            args_schema: vec![],
825        },
826    );
827    // VM.3d-2: vim's `n` / `N` / `*` / `#`. Charwise and exclusive (vim 9.2:
828    // `dn` from 1,1 deletes `alpha `), and jumps. `*` / `#` repeat the search
829    // exactly as `n` does: the host records the word under the cursor as the
830    // search before the motion runs (`Editor::capture_search_word`), so there's
831    // one word rule, not two. They were host actions, so `dn` / `vn` / `d*`
832    // were unbound.
833    let search_spec =
834        |apply: fn(&MotionContext) -> Result<MotionResult, CommandError>| MotionSpec {
835            curswant: crate::registry::CurswantEffect::default(),
836            jump: true,
837            exclusive: true,
838            apply: Arc::new(apply),
839            args_schema: vec![],
840        };
841    let search_next = registry.register_motion(
842        "motion:search-next",
843        "Repeat the last search in its own direction (vim's `n`).",
844        search_spec(motion_search_next),
845    );
846    let search_prev = registry.register_motion(
847        "motion:search-prev",
848        "Repeat the last search in the opposite direction (vim's `N`).",
849        search_spec(motion_search_prev),
850    );
851    let search_word_forward = registry.register_motion(
852        "motion:search-word-forward",
853        "Search forward for the word under the cursor (vim's `*`).",
854        search_spec(motion_search_next),
855    );
856    let search_word_backward = registry.register_motion(
857        "motion:search-word-backward",
858        "Search backward for the word under the cursor (vim's `#`).",
859        search_spec(motion_search_next),
860    );
861    // VM.3d-3: `g*` / `g#` — the same search without `\<` / `\>`, so it finds
862    // the word inside longer ones (`g*` on `foo` hits `foobar`). Separate
863    // motions rather than an argument because the PATTERN differs, and the
864    // pattern is what the host builds before the motion runs.
865    let search_word_forward_partial = registry.register_motion(
866        "motion:search-word-forward-partial",
867        "Search forward for the word under the cursor, not only whole words (vim's `g*`).",
868        search_spec(motion_search_next),
869    );
870    let search_word_backward_partial = registry.register_motion(
871        "motion:search-word-backward-partial",
872        "Search backward for the word under the cursor, not only whole words (vim's `g#`).",
873        search_spec(motion_search_next),
874    );
875    // VM.3e: vim's `'x` / `` `x ``, which were actions, so `d'a` / `v`a` were
876    // unbound. `'x` is linewise and lands on the mark line's first non-blank;
877    // `` `x `` is charwise and exclusive (vim 9.2: `d'a` deletes whole lines,
878    // `` d`a `` stops before the mark). Both jump. The mark name arrives as
879    // `Args::Char`, captured by the keymap's `{char}` wildcard as `f`'s is.
880    // VM.3g-2: vim's `gj` / `gk` / `g0` / `g$`. They were host ACTIONS, so
881    // `dgj`, `yg$` and `vgj` were unbound; vim composes all of them. `gj` / `gk`
882    // keep the shared goal column (vim gives `j` and `gj` the same one); `g$` is
883    // INCLUSIVE, the others exclusive; none is a jump.
884    let display_line_down = registry.register_motion(
885        "motion:display-line-down",
886        "Move one display row down, keeping the screen column (vim's `gj`).",
887        MotionSpec {
888            jump: false,
889            exclusive: true,
890            // Measured in vim 9.2 (`vimcheck_gj_curswant.vim`): `gj` SETS the
891            // goal from where it lands — 2,5 → 2,85 records 85, and crossing to
892            // the next line records the column there — unlike `j`, which keeps
893            // its goal across a short line. The asymmetry is real: `gj` recomputes
894            // the virtual column for the row it reached.
895            curswant: crate::registry::CurswantEffect::default(),
896            apply: Arc::new(motion_display_line_down),
897            args_schema: vec![],
898        },
899    );
900    let display_line_up = registry.register_motion(
901        "motion:display-line-up",
902        "Move one display row up, keeping the screen column (vim's `gk`).",
903        MotionSpec {
904            jump: false,
905            exclusive: true,
906            // As `gj` above: vim records the landing column (`gk` from 2,165 →
907            // 2,85 records 85).
908            curswant: crate::registry::CurswantEffect::default(),
909            apply: Arc::new(motion_display_line_up),
910            args_schema: vec![],
911        },
912    );
913    let display_line_start = registry.register_motion(
914        "motion:display-line-start",
915        "Move to the first column of the display row (vim's `g0`).",
916        MotionSpec {
917            jump: false,
918            exclusive: true,
919            curswant: crate::registry::CurswantEffect::default(),
920            apply: Arc::new(motion_display_line_start),
921            args_schema: vec![],
922        },
923    );
924    let display_line_end = registry.register_motion(
925        "motion:display-line-end",
926        "Move to the last column of the display row (vim's `g$`).",
927        MotionSpec {
928            jump: false,
929            exclusive: false,
930            curswant: crate::registry::CurswantEffect::default(),
931            apply: Arc::new(motion_display_line_end),
932            args_schema: vec![],
933        },
934    );
935    let mark_line = registry.register_motion(
936        "motion:mark-line",
937        "Go to the first non-blank of the line of mark `args.char` (vim's `'`).",
938        MotionSpec {
939            curswant: crate::registry::CurswantEffect::default(),
940            jump: true,
941            exclusive: false,
942            apply: Arc::new(motion_mark_line),
943            args_schema: vec![],
944        },
945    );
946    let mark_exact = registry.register_motion(
947        "motion:mark-exact",
948        "Go to the exact position of mark `args.char` (vim's `` ` ``).",
949        MotionSpec {
950            curswant: crate::registry::CurswantEffect::default(),
951            jump: true,
952            exclusive: true,
953            apply: Arc::new(motion_mark_exact),
954            args_schema: vec![],
955        },
956    );
957    // VM.3f: vim's `H` / `M` / `L`, which were actions, so `dL` / `yH` / `vM`
958    // were unbound. Linewise jumps (vim 9.2: `dL` deletes whole lines to the
959    // bottom of the window).
960    let viewport_spec =
961        |apply: fn(&MotionContext) -> Result<MotionResult, CommandError>| MotionSpec {
962            curswant: crate::registry::CurswantEffect::default(),
963            jump: true,
964            exclusive: false,
965            apply: Arc::new(apply),
966            args_schema: vec![],
967        };
968    let viewport_top = registry.register_motion(
969        "motion:viewport-top",
970        "Go to the `count`-th line from the top of the window (vim's `H`).",
971        viewport_spec(motion_viewport_top),
972    );
973    let viewport_middle = registry.register_motion(
974        "motion:viewport-middle",
975        "Go to the middle line of the window (vim's `M`).",
976        viewport_spec(motion_viewport_middle),
977    );
978    let viewport_bottom = registry.register_motion(
979        "motion:viewport-bottom",
980        "Go to the `count`-th line from the bottom of the window (vim's `L`).",
981        viewport_spec(motion_viewport_bottom),
982    );
983
984    Builtins {
985        word_forward,
986        word_backward,
987        word_end,
988        first_non_blank,
989        find_char_forward,
990        find_char_backward,
991        till_char_forward,
992        till_char_backward,
993        big_word_forward,
994        big_word_backward,
995        big_word_end,
996        paragraph_forward,
997        paragraph_backward,
998        sentence_forward,
999        sentence_backward,
1000        char_left,
1001        char_right,
1002        line_up,
1003        line_down,
1004        line_start,
1005        line_end,
1006        goto_first_line,
1007        goto_last_line,
1008        find_repeat,
1009        find_repeat_reverse,
1010        match_pair,
1011        goto_next_fold,
1012        goto_prev_fold,
1013        search_next,
1014        search_prev,
1015        search_word_forward,
1016        search_word_backward,
1017        search_word_forward_partial,
1018        search_word_backward_partial,
1019        mark_line,
1020        display_line_down,
1021        display_line_up,
1022        display_line_start,
1023        display_line_end,
1024        mark_exact,
1025        viewport_top,
1026        viewport_middle,
1027        viewport_bottom,
1028        delete,
1029        change,
1030        yank,
1031        indent_left,
1032        reindent,
1033        reflow,
1034        reformat,
1035        create_fold,
1036        indent_right,
1037        upper,
1038        lower,
1039        toggle_case,
1040        search,
1041        replace_char,
1042        inner_paragraph,
1043        around_paragraph,
1044        inner_sentence,
1045        around_sentence,
1046        inner_word,
1047        around_word,
1048        inner_quote_double,
1049        around_quote_double,
1050        inner_quote_single,
1051        around_quote_single,
1052        inner_quote_backtick,
1053        around_quote_backtick,
1054        inner_paren,
1055        around_paren,
1056        inner_bracket,
1057        around_bracket,
1058        inner_brace,
1059        around_brace,
1060        inner_tag,
1061        around_tag,
1062        inner_big_word,
1063        around_big_word,
1064        inner_angle,
1065        around_angle,
1066        inner_comment,
1067        around_comment,
1068    }
1069}
1070
1071/// The ids of every native built-in, as returned by [`populate`].
1072///
1073/// A typed shortcut for callers that hold the populated registry and need a
1074/// specific builtin without a name lookup (the keystroke parser, the
1075/// host's keymap wiring, tests, benches). Every field is also reachable by
1076/// its registered name through [`CommandRegistry::id_by_name`]; the doc on
1077/// each field gives that name and the vim key it implements. `Default`
1078/// yields placeholder ids that name nothing, for code that needs a value
1079/// before the registry is populated.
1080#[derive(Debug, Clone, Copy, Default)]
1081pub struct Builtins {
1082    /// `motion:word-forward` — vim's `w`: start of the next word (3-way word/punct/blank classes). Exclusive; tagged word-forward so `dw` / `cw` stop at the line end.
1083    pub word_forward: MotionId,
1084    /// `motion:word-backward` — vim's `b`: start of the previous word.
1085    pub word_backward: MotionId,
1086    /// `motion:word-end` — vim's `e`: last byte of the current or next word. Inclusive.
1087    pub word_end: MotionId,
1088    /// `motion:first-non-blank` — vim's `^`: first non-blank byte of the line. Exclusive.
1089    pub first_non_blank: MotionId,
1090    /// `motion:find-char-forward` — vim's `f{char}`: next `args.char` on the line, landing on it. Inclusive.
1091    pub find_char_forward: MotionId,
1092    /// `motion:find-char-backward` — vim's `F{char}`: previous `args.char` on the line, landing on it.
1093    pub find_char_backward: MotionId,
1094    /// `motion:till-char-forward` — vim's `t{char}`: one byte before the next `args.char` on the line.
1095    pub till_char_forward: MotionId,
1096    /// `motion:till-char-backward` — vim's `T{char}`: one byte after the previous `args.char` on the line.
1097    pub till_char_backward: MotionId,
1098    /// `motion:big-word-forward` — vim's `W`: start of the next whitespace-delimited WORD. Tagged word-forward, like `w`.
1099    pub big_word_forward: MotionId,
1100    /// `motion:big-word-backward` — vim's `B`: start of the previous WORD.
1101    pub big_word_backward: MotionId,
1102    /// `motion:big-word-end` — vim's `E`: last byte of the current or next WORD.
1103    pub big_word_end: MotionId,
1104    /// `motion:paragraph-forward` — vim's `}`: the next blank line (paragraph boundary). A jump.
1105    pub paragraph_forward: MotionId,
1106    /// `motion:paragraph-backward` — vim's `{`: the previous paragraph boundary. A jump.
1107    pub paragraph_backward: MotionId,
1108    /// `motion:sentence-forward` — vim's `)`: start of the next sentence. A jump.
1109    pub sentence_forward: MotionId,
1110    /// `motion:sentence-backward` — vim's `(`: start of the previous sentence. A jump.
1111    pub sentence_backward: MotionId,
1112    /// `motion:char-left` — vim's `h`: one character left, within the line (steps whole UTF-8 scalars).
1113    pub char_left: MotionId,
1114    /// `motion:char-right` — vim's `l`: one character right, within the line.
1115    pub char_right: MotionId,
1116    /// `motion:line-up` — vim's `k`: one line up, aiming at the goal column (keeps `curswant`).
1117    pub line_up: MotionId,
1118    /// `motion:line-down` — vim's `j`: one line down, aiming at the goal column (keeps `curswant`).
1119    pub line_down: MotionId,
1120    /// `motion:line-start` — vim's `0`: first byte of the line. Exclusive.
1121    pub line_start: MotionId,
1122    /// `motion:line-end` — vim's `$`: end of the line; pins the goal column to end-of-line.
1123    pub line_end: MotionId,
1124    /// `motion:goto-first-line` — vim's `gg` (`{count}gg` goes to that line). Linewise jump.
1125    pub goto_first_line: MotionId,
1126    /// `motion:goto-last-line` — vim's `G` (`{count}G` goes to that line). Linewise jump.
1127    pub goto_last_line: MotionId,
1128    /// VM.3c: vim's `;` and `,` — repeat the last `f` / `F` / `t` / `T` in
1129    /// the same or the opposite direction. Motions for the same reason `%` is:
1130    /// vim composes them (`d;`) and extends a selection with them.
1131    pub find_repeat: MotionId,
1132    /// `motion:find-repeat-reverse` — vim's `,`: the last find, reversed (see [`FindKind::reversed`](crate::FindKind::reversed)).
1133    pub find_repeat_reverse: MotionId,
1134    /// VM.3b: vim's `%`. A MOTION, not an action — vim composes it
1135    /// (`d%` deletes a bracketed span, `v%` selects one), and it took being
1136    /// typed as an action for `d%` and `v%` to be silently unbound here.
1137    pub match_pair: MotionId,
1138    /// VM.3i: vim's `zj` / `zk`, as the motions they are in vim.
1139    pub goto_next_fold: MotionId,
1140    /// `motion:goto-prev-fold` — vim's `zk`: end of the previous fold.
1141    pub goto_prev_fold: MotionId,
1142    /// VM.3d-2: vim's `n` / `N` / `*` / `#`, as the motions they are in vim.
1143    pub search_next: MotionId,
1144    /// `motion:search-prev` — vim's `N`: repeat the last search in the opposite direction.
1145    pub search_prev: MotionId,
1146    /// `motion:search-word-forward` — vim's `*`: search forward for the whole word under the cursor.
1147    pub search_word_forward: MotionId,
1148    /// `motion:search-word-backward` — vim's `#`: search backward for the whole word under the cursor.
1149    pub search_word_backward: MotionId,
1150    /// VM.3d-3: vim's `g*` / `g#` — the word under the cursor, matched
1151    /// anywhere rather than only as a whole word.
1152    pub search_word_forward_partial: MotionId,
1153    /// `motion:search-word-backward-partial` — vim's `g#`: `#` without the whole-word boundaries.
1154    pub search_word_backward_partial: MotionId,
1155    /// VM.3e: vim's `'x` / `` `x ``, as the motions they are in vim.
1156    pub mark_line: MotionId,
1157    /// VM.3g-2: vim's `gj` / `gk` / `g0` / `g$`, as the motions they are.
1158    pub display_line_down: MotionId,
1159    /// `motion:display-line-up` — vim's `gk`: one display row up, keeping the screen column.
1160    pub display_line_up: MotionId,
1161    /// `motion:display-line-start` — vim's `g0`: first column of the display row.
1162    pub display_line_start: MotionId,
1163    /// `motion:display-line-end` — vim's `g$`: last column of the display row.
1164    pub display_line_end: MotionId,
1165    /// `motion:mark-exact` — vim's `` `x ``: the exact position of mark `args.char`.
1166    pub mark_exact: MotionId,
1167    /// VM.3f: vim's `H` / `M` / `L`, as the motions they are in vim.
1168    pub viewport_top: MotionId,
1169    /// `motion:viewport-middle` — vim's `M`: the middle of the lines shown.
1170    pub viewport_middle: MotionId,
1171    /// `motion:viewport-bottom` — vim's `L`: the `count`-th line from the bottom of the window.
1172    pub viewport_bottom: MotionId,
1173    /// `operator:delete` — vim's `d`: delete the range and yank it (unnamed register, or `"x`).
1174    pub delete: OperatorId,
1175    /// `operator:change` — vim's `c`: delete the range and enter Insert mode.
1176    pub change: OperatorId,
1177    /// `operator:yank` — vim's `y`: copy the range into the register without editing.
1178    pub yank: OperatorId,
1179    /// `operator:indent-left` — vim's `<`: remove one indent unit (`shiftwidth` / `expandtab`, resolved by the host into [`OperatorContext::indent`](crate::OperatorContext::indent)) from each non-blank line.
1180    pub indent_left: OperatorId,
1181    /// `operator:indent-right` — vim's `>`: add one indent unit to each non-blank line.
1182    pub indent_right: OperatorId,
1183    /// IN.7: vim's `=` — reindent, leading whitespace only.
1184    pub reindent: OperatorId,
1185    /// RF.2: vim's `gq` and `gw` — reflow to `textwidth`. ONE operator
1186    /// behind both chords; they differ only in cursor placement in vim,
1187    /// and this preserves the cursor (`gw`'s behaviour).
1188    pub reflow: OperatorId,
1189    /// RF.6: `g=` — the operator form of `:format`. Not a vim chord;
1190    /// see `operator_reformat` for why it exists.
1191    pub reformat: OperatorId,
1192    /// VM.3h: vim's `zf`, as the operator it is in vim.
1193    pub create_fold: OperatorId,
1194    /// `operator:upper` — vim's `gU`: uppercase ASCII letters in the range.
1195    pub upper: OperatorId,
1196    /// `operator:lower` — vim's `gu`: lowercase ASCII letters in the range.
1197    pub lower: OperatorId,
1198    /// `operator:toggle-case` — vim's `g~`: toggle the case of ASCII letters in the range.
1199    pub toggle_case: OperatorId,
1200    /// `operator:search` — `g/` (a Lattice extension, not vim): project-search for the spanned text; emits the same `SearchTrigger` as `:search`.
1201    pub search: OperatorId,
1202    /// `operator:replace-char` — vim's `r{char}` / Visual `r`: overwrite each non-newline character in the range with `args.char`.
1203    pub replace_char: OperatorId,
1204    /// `text-object:inner-paragraph` — vim's `ip`: the run of non-blank lines around the cursor.
1205    pub inner_paragraph: TextObjectId,
1206    /// `text-object:around-paragraph` — vim's `ap`: `ip` plus trailing blank lines.
1207    pub around_paragraph: TextObjectId,
1208    /// `text-object:inner-sentence` — vim's `is`: the sentence around the cursor.
1209    pub inner_sentence: TextObjectId,
1210    /// `text-object:around-sentence` — vim's `as`: `is` plus trailing whitespace.
1211    pub around_sentence: TextObjectId,
1212    /// `text-object:inner-word` — vim's `iw`: the word around the cursor.
1213    pub inner_word: TextObjectId,
1214    /// `text-object:around-word` — vim's `aw`: `iw` plus trailing whitespace.
1215    pub around_word: TextObjectId,
1216    /// `text-object:inner-quote-double` — vim's `i"`: inside the surrounding double quotes.
1217    pub inner_quote_double: TextObjectId,
1218    /// `text-object:around-quote-double` — vim's `a"`: `i"` including the quotes.
1219    pub around_quote_double: TextObjectId,
1220    /// `text-object:inner-quote-single` — vim's `i'`: inside the surrounding single quotes.
1221    pub inner_quote_single: TextObjectId,
1222    /// `text-object:around-quote-single` — vim's `a'`: `i'` including the quotes.
1223    pub around_quote_single: TextObjectId,
1224    /// `text-object:inner-quote-backtick` — vim's ``i` ``: inside the surrounding backticks.
1225    pub inner_quote_backtick: TextObjectId,
1226    /// `text-object:around-quote-backtick` — vim's ``a` ``: ``i` `` including the backticks.
1227    pub around_quote_backtick: TextObjectId,
1228    /// `text-object:inner-paren` — vim's `i(`: inside the innermost enclosing `()`.
1229    pub inner_paren: TextObjectId,
1230    /// `text-object:around-paren` — vim's `a(`: `i(` including the parentheses.
1231    pub around_paren: TextObjectId,
1232    /// `text-object:inner-bracket` — vim's `i[`: inside the innermost enclosing `[]`.
1233    pub inner_bracket: TextObjectId,
1234    /// `text-object:around-bracket` — vim's `a[`: `i[` including the brackets.
1235    pub around_bracket: TextObjectId,
1236    /// `text-object:inner-brace` — vim's `i{`: inside the innermost enclosing `{}`.
1237    pub inner_brace: TextObjectId,
1238    /// `text-object:around-brace` — vim's `a{`: `i{` including the braces.
1239    pub around_brace: TextObjectId,
1240    /// `text-object:inner-tag` — vim's `it`: inside the innermost enclosing XML/HTML tag pair.
1241    pub inner_tag: TextObjectId,
1242    /// `text-object:around-tag` — vim's `at`: `it` including the tags.
1243    pub around_tag: TextObjectId,
1244    /// `text-object:inner-big-word` — vim's `iW`: the WORD around the cursor.
1245    pub inner_big_word: TextObjectId,
1246    /// `text-object:around-big-word` — vim's `aW`: `iW` plus trailing whitespace.
1247    pub around_big_word: TextObjectId,
1248    /// `text-object:inner-angle` — vim's `i<`: inside the innermost enclosing `<>`.
1249    pub inner_angle: TextObjectId,
1250    /// `text-object:around-angle` — vim's `a<`: `i<` including the angle brackets.
1251    pub around_angle: TextObjectId,
1252    /// `text-object:inner-comment` — `iC` (Lattice): the comment text, first line's leader stripped. Needs [`CommentSyntax`](crate::CommentSyntax) from the host.
1253    pub inner_comment: TextObjectId,
1254    /// `text-object:around-comment` — `aC` (Lattice): the contiguous run of comment lines, markers included.
1255    pub around_comment: TextObjectId,
1256}
1257
1258// ---- Motion: word-forward ----
1259
1260fn motion_word_forward(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
1261    // Advance `count` words using vim's 3-way class model ([`word_class`]):
1262    // skip the rest of the cursor's current word (its non-blank class run),
1263    // then skip blanks to the next word/punct start. So `foo.bar` from `f`
1264    // stops on `.` (a punctuation word), not `bar`. Newlines are blanks, so
1265    // this crosses lines onto the first word of the next line.
1266    let text = ctx.buffer.as_string();
1267    let bytes = text.as_bytes();
1268    let total = bytes.len();
1269    let count = ctx.count.get().max(1);
1270    let mut idx = ctx
1271        .buffer
1272        .position_to_byte(ctx.from)
1273        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
1274
1275    for _ in 0..count {
1276        if idx >= total {
1277            break;
1278        }
1279        // Skip the rest of the current word: the run of the cursor's class,
1280        // unless the cursor already sits on a blank (nothing to skip here).
1281        let start_class = word_class(bytes[idx]);
1282        if start_class != WordClass::Blank {
1283            while idx < total && word_class(bytes[idx]) == start_class {
1284                idx += 1;
1285            }
1286        }
1287        // Skip blanks (whitespace / newlines) to the next word start.
1288        while idx < total && word_class(bytes[idx]) == WordClass::Blank {
1289            idx += 1;
1290        }
1291    }
1292
1293    // At/after EOF, clamp to the end-of-buffer position (vim `w` at the last
1294    // word lands past the final char; `dw` then deletes through the end).
1295    let target = ctx
1296        .buffer
1297        .byte_to_position(idx.min(total))
1298        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
1299
1300    Ok(MotionResult {
1301        curswant: None,
1302        target,
1303        linewise: false,
1304        exclusive: None,
1305        notice: None,
1306    })
1307}
1308
1309fn is_word_byte(b: u8) -> bool {
1310    b.is_ascii_alphanumeric() || b == b'_'
1311}
1312
1313fn is_blank_byte(b: u8) -> bool {
1314    b == b' ' || b == b'\t'
1315}
1316
1317/// "WORD" boundary in vim: any non-whitespace run is a WORD; whitespace
1318/// (incl. newline) separates WORDs.
1319fn is_big_word_byte(b: u8) -> bool {
1320    !b.is_ascii_whitespace()
1321}
1322
1323/// Vim's **3-way** word classification, the boundary model for `w` / `b` /
1324/// `e`. A "word" is a maximal run of ONE non-blank class, so `foo.bar` is
1325/// three words — `foo` (Word), `.` (Punct), `bar` (Word) — and a bare `w`
1326/// stops on the `.` rather than skipping it. Blanks (whitespace incl.
1327/// newline) separate words. This is distinct from the 2-way "WORD" model
1328/// (`is_big_word_byte`) that `W` / `B` / `E` use.
1329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1330enum WordClass {
1331    Blank,
1332    Word,
1333    Punct,
1334}
1335
1336fn word_class(b: u8) -> WordClass {
1337    if is_word_byte(b) {
1338        WordClass::Word
1339    } else if b.is_ascii_whitespace() {
1340        WordClass::Blank
1341    } else {
1342        WordClass::Punct
1343    }
1344}
1345
1346/// The 2-way "WORD" classification for `W` / `B` / `E` and `iW` / `aW`: a
1347/// maximal non-whitespace run is one WORD (punctuation folds in), whitespace
1348/// separates them. Reuses [`WordClass`] with every non-blank byte as `Word`
1349/// so punctuation never splits a WORD.
1350fn big_word_class(b: u8) -> WordClass {
1351    if b.is_ascii_whitespace() {
1352        WordClass::Blank
1353    } else {
1354        WordClass::Word
1355    }
1356}
1357
1358// ---- Motion: word-backward (vim's `b`) ----
1359
1360fn motion_word_backward(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
1361    let text = ctx.buffer.as_string();
1362    let bytes = text.as_bytes();
1363    let count = ctx.count.get().max(1);
1364    let mut idx = ctx
1365        .buffer
1366        .position_to_byte(ctx.from)
1367        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
1368
1369    for _ in 0..count {
1370        if idx == 0 {
1371            break;
1372        }
1373        // Step one byte back, skip blanks (whitespace / newlines), then walk
1374        // back to the start of that word — the run of ONE non-blank class
1375        // ([`word_class`]), so `b` lands on a punctuation run rather than
1376        // skipping it.
1377        idx -= 1;
1378        while idx > 0 && word_class(bytes[idx]) == WordClass::Blank {
1379            idx -= 1;
1380        }
1381        let c = word_class(bytes[idx]);
1382        while idx > 0 && word_class(bytes[idx - 1]) == c {
1383            idx -= 1;
1384        }
1385    }
1386
1387    let target = ctx
1388        .buffer
1389        .byte_to_position(idx)
1390        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
1391    Ok(MotionResult {
1392        curswant: None,
1393        target,
1394        linewise: false,
1395        exclusive: None,
1396        notice: None,
1397    })
1398}
1399
1400// ---- Motion: word-end (vim's `e`) ----
1401
1402fn motion_word_end(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
1403    let text = ctx.buffer.as_string();
1404    let bytes = text.as_bytes();
1405    let total = bytes.len();
1406    let count = ctx.count.get().max(1);
1407    let mut idx = ctx
1408        .buffer
1409        .position_to_byte(ctx.from)
1410        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
1411
1412    for _ in 0..count {
1413        if idx >= total.saturating_sub(1) {
1414            break;
1415        }
1416        // Step one byte forward, skip blanks, then advance to the last byte
1417        // of that word — the run of ONE non-blank class ([`word_class`]), so
1418        // `e` stops at the end of a punctuation run rather than skipping it.
1419        idx += 1;
1420        while idx < total && word_class(bytes[idx]) == WordClass::Blank {
1421            idx += 1;
1422        }
1423        if idx >= total {
1424            idx = total.saturating_sub(1);
1425            break;
1426        }
1427        let c = word_class(bytes[idx]);
1428        while idx + 1 < total && word_class(bytes[idx + 1]) == c {
1429            idx += 1;
1430        }
1431    }
1432
1433    let target = ctx
1434        .buffer
1435        .byte_to_position(idx)
1436        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
1437    Ok(MotionResult {
1438        curswant: None,
1439        target,
1440        linewise: false,
1441        exclusive: None,
1442        notice: None,
1443    })
1444}
1445
1446// ---- Paragraph motions (vim's `}`, `{`) ----
1447//
1448// A paragraph is a maximal run of non-blank lines. `}` lands on the
1449// next blank line at or after `cursor.line + 1`; `{` lands on the
1450// previous blank line. If no boundary exists, lands at end / start of
1451// buffer respectively.
1452
1453fn line_is_blank(text: &str, line: u32) -> bool {
1454    text.split_inclusive('\n')
1455        .nth(line as usize)
1456        .map(|l| l.trim_end_matches('\n').trim().is_empty())
1457        .unwrap_or(true)
1458}
1459
1460fn buffer_last_line(text: &str) -> u32 {
1461    let lc = text.split_inclusive('\n').count() as u32;
1462    if text.ends_with('\n') {
1463        lc.saturating_sub(2)
1464    } else {
1465        lc.saturating_sub(1)
1466    }
1467}
1468
1469/// Vim's `%` — from the cursor, find the first bracket at or after it **on
1470/// this line**, then jump to its partner.
1471///
1472/// Two details that look like implementation choices and are actually vim's
1473/// semantics:
1474///
1475/// - The search for the *starting* bracket stops at the newline. `%` on a line
1476///   with no bracket does nothing; it does not go hunting down the buffer.
1477/// - Nesting is counted, not pattern-matched, so the partner of the outer `(`
1478///   in `(a(b)c)` is the last `)`, not the first.
1479///
1480/// Byte-indexed on the raw bytes: every bracket is ASCII, and a multi-byte
1481/// char cannot contain a byte that equals one, so scanning bytes can neither
1482/// miss a bracket nor invent one inside a UTF-8 sequence.
1483///
1484/// Finding nothing returns the cursor unmoved rather than an error — vim
1485/// beeps and stays put, and a `%` that failed should not make `d%` delete
1486/// something surprising.
1487fn motion_match_pair(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
1488    let unmoved = Ok(MotionResult {
1489        curswant: None,
1490        target: ctx.from,
1491        linewise: false,
1492        exclusive: None,
1493        notice: None,
1494    });
1495    let text = ctx.buffer.as_string();
1496    let bytes = text.as_bytes();
1497    let Ok(cursor_byte) = ctx.buffer.position_to_byte(ctx.from) else {
1498        return unmoved;
1499    };
1500
1501    // The starting bracket: first one at or after the cursor, same line.
1502    let mut idx = cursor_byte;
1503    let mut found = None;
1504    while idx < bytes.len() && bytes[idx] != b'\n' {
1505        if matches!(bytes[idx], b'(' | b')' | b'[' | b']' | b'{' | b'}') {
1506            found = Some((idx, bytes[idx]));
1507            break;
1508        }
1509        idx += 1;
1510    }
1511    let Some((start, b)) = found else {
1512        return unmoved;
1513    };
1514    let (open, close, forward) = match b {
1515        b'(' => (b'(', b')', true),
1516        b')' => (b'(', b')', false),
1517        b'[' => (b'[', b']', true),
1518        b']' => (b'[', b']', false),
1519        b'{' => (b'{', b'}', true),
1520        b'}' => (b'{', b'}', false),
1521        _ => return unmoved,
1522    };
1523
1524    let target = if forward {
1525        scan_forward_for_match(bytes, start, open, close)
1526    } else {
1527        scan_backward_for_match(bytes, start, open, close)
1528    };
1529    match target.and_then(|t| ctx.buffer.byte_to_position(t).ok()) {
1530        Some(pos) => Ok(MotionResult {
1531            curswant: None,
1532            target: pos,
1533            linewise: false,
1534            exclusive: None,
1535            notice: None,
1536        }),
1537        None => unmoved,
1538    }
1539}
1540
1541/// Walk forward from an opening bracket to its partner, counting nesting.
1542/// `from` must index the opening bracket itself, so depth starts at 1 on the
1543/// first iteration and the partner is where it returns to 0.
1544fn scan_forward_for_match(bytes: &[u8], from: usize, open: u8, close: u8) -> Option<usize> {
1545    let mut depth = 0i32;
1546    let mut i = from;
1547    loop {
1548        if i >= bytes.len() {
1549            return None;
1550        }
1551        let b = bytes[i];
1552        if b == open {
1553            depth += 1;
1554        } else if b == close {
1555            depth -= 1;
1556            if depth == 0 {
1557                return Some(i);
1558            }
1559        }
1560        i += 1;
1561    }
1562}
1563
1564/// The backward peer. Walks down to index 0 and checks `i == 0` before
1565/// decrementing, because `usize` has no -1 to detect the end with.
1566fn scan_backward_for_match(bytes: &[u8], from: usize, open: u8, close: u8) -> Option<usize> {
1567    let mut depth = 0i32;
1568    let mut i = from;
1569    loop {
1570        let b = bytes[i];
1571        if b == close {
1572            depth += 1;
1573        } else if b == open {
1574            depth -= 1;
1575            if depth == 0 {
1576                return Some(i);
1577            }
1578        }
1579        if i == 0 {
1580            return None;
1581        }
1582        i -= 1;
1583    }
1584}
1585
1586/// VM.3f: `H` / `M` / `L` — a line of the window, linewise. Where on it follows
1587/// `startofline`: the first non-blank (vim's default) or, with
1588/// `nostartofline`, the cursor's column clamped to the line. With no window the
1589/// motion fails and moves nothing.
1590fn motion_viewport(
1591    ctx: &MotionContext,
1592    pos: crate::app_effect::ViewportPos,
1593) -> Result<MotionResult, CommandError> {
1594    let viewport = ctx.viewport.ok_or(CommandError::MotionFailed)?;
1595    let line = viewport
1596        .viewport_line(pos, ctx.count.get())
1597        .ok_or(CommandError::MotionFailed)?;
1598    let line = scrolloff_adjusted(ctx, viewport, pos, line).min(last_addressable_line(ctx.buffer));
1599    Ok(MotionResult {
1600        curswant: None,
1601        // VM.3j-1: the same helper `gg` / `G` use — this was an inline copy.
1602        target: startofline_target(ctx, line),
1603        linewise: true,
1604        exclusive: None,
1605        notice: None,
1606    })
1607}
1608
1609/// VM.3f: keep `H` / `L` `scrolloff` lines inside the window's edges.
1610///
1611/// Vim adjusts both "unless an operator is pending" (`:h H`), and it does NOT
1612/// adjust at a file edge: a window showing the first line leaves `H` on it, and
1613/// one showing the last leaves `L` there — the margin exists to keep context on
1614/// screen, and past the end of the file there is none to keep. `M` is never
1615/// adjusted.
1616///
1617/// Measured in vim 9.2 with an 11-line window over 100 lines, `50Gzz` (top 45,
1618/// bottom 55): `H` → 48 and `L` → 52 with `scrolloff=3`, 45 and 55 with `0`;
1619/// `gg`-then-`H` → 1 and `G`-then-`L` → 100; `dH` deletes from 45, unadjusted.
1620fn scrolloff_adjusted(
1621    ctx: &MotionContext,
1622    viewport: &dyn crate::registry::ViewportResolver,
1623    pos: crate::app_effect::ViewportPos,
1624    line: u32,
1625) -> u32 {
1626    use crate::app_effect::ViewportPos;
1627    if ctx.scrolloff == 0 || ctx.operator_pending {
1628        return line;
1629    }
1630    // The window's own edges, which is what the margin is measured from —
1631    // `viewport_line` with a count of 1 is exactly "the first / last line
1632    // shown", so no second resolver call shape is needed.
1633    match pos {
1634        ViewportPos::Top => match viewport.viewport_line(ViewportPos::Top, 1) {
1635            // At the top of the FILE there is nothing above to keep in view.
1636            Some(top) if top > 0 => line.max(top + ctx.scrolloff),
1637            _ => line,
1638        },
1639        ViewportPos::Bottom => match viewport.viewport_line(ViewportPos::Bottom, 1) {
1640            Some(bottom) if bottom < last_addressable_line(ctx.buffer) => {
1641                line.min(bottom.saturating_sub(ctx.scrolloff))
1642            }
1643            _ => line,
1644        },
1645        ViewportPos::Middle => line,
1646    }
1647}
1648
1649fn motion_viewport_top(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
1650    motion_viewport(ctx, crate::app_effect::ViewportPos::Top)
1651}
1652
1653fn motion_viewport_middle(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
1654    motion_viewport(ctx, crate::app_effect::ViewportPos::Middle)
1655}
1656
1657fn motion_viewport_bottom(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
1658    motion_viewport(ctx, crate::app_effect::ViewportPos::Bottom)
1659}
1660
1661/// VM.3e: where mark `args.char` is, clamped into the buffer (a mark on a line
1662/// since deleted still resolves, as the host's jump always has). vim 9.2: an
1663/// unset mark is E20, and the motion failing cancels an operator it feeds.
1664fn mark_position(ctx: &MotionContext) -> Result<Position, CommandError> {
1665    let name = match &ctx.args {
1666        crate::args::Args::Char(c) if c.is_ascii_alphanumeric() => *c,
1667        crate::args::Args::Char(_) => {
1668            return Err(CommandError::User("E78: Unknown mark".to_string()));
1669        }
1670        _ => return Err(CommandError::InvalidArgs("'/` require Args::Char")),
1671    };
1672    let Some(pos) = ctx.marks.and_then(|marks| marks.mark(name)) else {
1673        return Err(CommandError::User("E20: Mark not set".to_string()));
1674    };
1675    let line = pos.line.min(last_addressable_line(ctx.buffer));
1676    Ok(Position::new(
1677        line,
1678        pos.byte.min(line_byte_len(ctx.buffer, line)),
1679    ))
1680}
1681
1682/// VM.3e: `'x` — the first non-blank of the mark's line, linewise. A count is
1683/// ignored, as in vim (`3'a` is `'a`).
1684fn motion_mark_line(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
1685    let mark = mark_position(ctx)?;
1686    let line_text = ctx.buffer.line(mark.line).unwrap_or_default();
1687    let bytes = line_text.as_bytes();
1688    let mut col = 0usize;
1689    while col < bytes.len() && is_blank_byte(bytes[col]) {
1690        col += 1;
1691    }
1692    let col = (col as u32).min(line_byte_len(ctx.buffer, mark.line));
1693    Ok(MotionResult {
1694        curswant: None,
1695        target: Position::new(mark.line, col),
1696        linewise: true,
1697        exclusive: None,
1698        notice: None,
1699    })
1700}
1701
1702/// VM.3e: `` `x `` — the mark's exact position, charwise and exclusive.
1703fn motion_mark_exact(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
1704    Ok(MotionResult {
1705        curswant: None,
1706        target: mark_position(ctx)?,
1707        linewise: false,
1708        exclusive: None,
1709        notice: None,
1710    })
1711}
1712
1713/// VM.3d-2: `n` (`reverse = false`) / `N` — the last search again, `count`
1714/// times, from one character past the cursor so the match under it isn't found
1715/// again. vim's rules, checked in 9.2: the target is the match START (charwise,
1716/// exclusive), a search that wraps says so, and a pattern with no match fails
1717/// with E486, which cancels an operator (`dn` deletes nothing).
1718fn motion_search(ctx: &MotionContext, reverse: bool) -> Result<MotionResult, CommandError> {
1719    use crate::modal::SearchDirection;
1720    let Some(last) = ctx.last_search else {
1721        return Err(CommandError::User(
1722            "E35: no previous regular expression".to_string(),
1723        ));
1724    };
1725    let direction = match (last.direction, reverse) {
1726        (SearchDirection::Forward, false) | (SearchDirection::Backward, true) => {
1727            SearchDirection::Forward
1728        }
1729        (SearchDirection::Backward, false) | (SearchDirection::Forward, true) => {
1730            SearchDirection::Backward
1731        }
1732    };
1733    let regex = fancy_regex::Regex::new(&last.pattern)
1734        .map_err(|e| CommandError::User(format!("regex: {e}")))?;
1735    let core_direction = match direction {
1736        SearchDirection::Forward => lattice_core::search::Direction::Forward,
1737        SearchDirection::Backward => lattice_core::search::Direction::Backward,
1738    };
1739    let mut at = ctx.from;
1740    let mut wrapped = false;
1741    for _ in 0..ctx.count.get().max(1) {
1742        let from = step_for_search(ctx.buffer, at, direction);
1743        match lattice_core::search::find(ctx.buffer, &regex, from, core_direction, ctx.cancel)? {
1744            Some(hit) => {
1745                at = hit.range.start;
1746                wrapped |= hit.wrapped;
1747            }
1748            None => {
1749                return Err(CommandError::User(format!(
1750                    "E486: Pattern not found: {}",
1751                    last.pattern
1752                )));
1753            }
1754        }
1755    }
1756    Ok(MotionResult {
1757        curswant: None,
1758        target: at,
1759        linewise: false,
1760        exclusive: None,
1761        notice: wrapped.then_some(match direction {
1762            SearchDirection::Forward => crate::registry::MotionNotice::SearchHitBottom,
1763            SearchDirection::Backward => crate::registry::MotionNotice::SearchHitTop,
1764        }),
1765    })
1766}
1767
1768fn motion_search_next(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
1769    motion_search(ctx, false)
1770}
1771
1772fn motion_search_prev(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
1773    motion_search(ctx, true)
1774}
1775
1776/// VM.3d-2: one character in the search direction, so `n` skips the match the
1777/// cursor is on. The same rule as the host's `step_byte` for `repeat_search`: a
1778/// whole UTF-8 scalar forward (a mid-scalar offset would be snapped back onto
1779/// the match being skipped), across a line end to the next line's start, and
1780/// one byte back or to the previous line's end.
1781fn step_for_search(
1782    buffer: &lattice_core::Buffer,
1783    p: Position,
1784    direction: crate::modal::SearchDirection,
1785) -> Position {
1786    let line = buffer.line(p.line).unwrap_or_default();
1787    match direction {
1788        crate::modal::SearchDirection::Forward => {
1789            if p.byte < line_byte_len(buffer, p.line) {
1790                let mut byte = p.byte as usize + 1;
1791                while byte < line.len() && !line.is_char_boundary(byte) {
1792                    byte += 1;
1793                }
1794                Position::new(p.line, byte as u32)
1795            } else if p.line < last_addressable_line(buffer) {
1796                Position::new(p.line + 1, 0)
1797            } else {
1798                p
1799            }
1800        }
1801        crate::modal::SearchDirection::Backward => {
1802            if p.byte > 0 {
1803                let mut byte = (p.byte as usize - 1).min(line.len());
1804                while byte > 0 && !line.is_char_boundary(byte) {
1805                    byte -= 1;
1806                }
1807                Position::new(p.line, byte as u32)
1808            } else if p.line > 0 {
1809                Position::new(p.line - 1, line_byte_len(buffer, p.line - 1))
1810            } else {
1811                p
1812            }
1813        }
1814    }
1815}
1816
1817/// VM.3i: `zj` (`forward`) / `zk` — the next fold start / previous fold end,
1818/// `count` times. The host decides which folds are visible (a closed fold
1819/// counts as one, vim's rule); this only walks the answers.
1820///
1821/// No resolver, or no edge that way, leaves the cursor where it is: vim moves
1822/// nothing and says nothing. A count that runs out of folds stops at the last
1823/// edge it reached.
1824fn motion_goto_fold(ctx: &MotionContext, forward: bool) -> Result<MotionResult, CommandError> {
1825    let mut target = ctx.from;
1826    if let Some(folds) = ctx.fold_resolver {
1827        let mut line = ctx.from.line;
1828        for _ in 0..ctx.count.get().max(1) {
1829            match folds.fold_edge(line, forward) {
1830                Some(edge) => {
1831                    line = edge;
1832                    target = Position::new(edge, 0);
1833                }
1834                None => break,
1835            }
1836        }
1837    }
1838    Ok(MotionResult {
1839        curswant: None,
1840        target,
1841        linewise: false,
1842        exclusive: None,
1843        notice: None,
1844    })
1845}
1846
1847fn motion_goto_next_fold(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
1848    motion_goto_fold(ctx, true)
1849}
1850
1851fn motion_goto_prev_fold(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
1852    motion_goto_fold(ctx, false)
1853}
1854
1855fn motion_paragraph_forward(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
1856    let text = ctx.buffer.as_string();
1857    let count = ctx.count.get().max(1);
1858    let last = buffer_last_line(&text);
1859    let mut line = ctx.from.line;
1860    for _ in 0..count {
1861        // Step forward at least one line.
1862        if line > last {
1863            line = last;
1864            break;
1865        }
1866        line = line.saturating_add(1);
1867        // Skip current paragraph (non-blank lines).
1868        while line <= last && !line_is_blank(&text, line) {
1869            line = line.saturating_add(1);
1870        }
1871        if line > last {
1872            line = last;
1873            break;
1874        }
1875    }
1876    Ok(MotionResult {
1877        curswant: None,
1878        target: Position::new(line, 0),
1879        linewise: false,
1880        exclusive: None,
1881        notice: None,
1882    })
1883}
1884
1885fn motion_paragraph_backward(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
1886    let text = ctx.buffer.as_string();
1887    let count = ctx.count.get().max(1);
1888    let mut line = ctx.from.line;
1889    for _ in 0..count {
1890        if line == 0 {
1891            break;
1892        }
1893        line = line.saturating_sub(1);
1894        while line > 0 && !line_is_blank(&text, line) {
1895            line = line.saturating_sub(1);
1896        }
1897    }
1898    Ok(MotionResult {
1899        curswant: None,
1900        target: Position::new(line, 0),
1901        linewise: false,
1902        exclusive: None,
1903        notice: None,
1904    })
1905}
1906
1907// ---- Sentence motions and text objects (vim's `)`, `(`, `is`, `as`) ----
1908//
1909// A sentence ends with `.`, `!`, or `?` followed by whitespace (or
1910// EOL). v1 doesn't honor vim's nuance around closing brackets / quotes
1911// after the punctuation; just the simple form.
1912
1913fn is_sentence_end(b: u8) -> bool {
1914    matches!(b, b'.' | b'!' | b'?')
1915}
1916
1917/// Find the byte offset of the start of the next sentence at or after
1918/// `from`. Returns the index of the first non-whitespace byte AFTER a
1919/// sentence-ending punctuation followed by whitespace (or the end of
1920/// the buffer).
1921fn next_sentence_start(bytes: &[u8], from: usize) -> Option<usize> {
1922    let mut i = from;
1923    while i + 1 < bytes.len() {
1924        if is_sentence_end(bytes[i]) && bytes[i + 1].is_ascii_whitespace() {
1925            // Skip the punctuation + whitespace.
1926            let mut j = i + 2;
1927            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
1928                j += 1;
1929            }
1930            if j < bytes.len() {
1931                return Some(j);
1932            }
1933            return None;
1934        }
1935        i += 1;
1936    }
1937    None
1938}
1939
1940/// Find the byte offset of the start of the previous sentence (the
1941/// non-whitespace byte after a previous sentence end). Returns 0 if
1942/// there's no earlier sentence boundary.
1943fn prev_sentence_start(bytes: &[u8], from: usize) -> usize {
1944    if from == 0 {
1945        return 0;
1946    }
1947    // Walk back to find a sentence-end + whitespace pattern.
1948    let mut i = from.saturating_sub(1);
1949    while i > 0 {
1950        if is_sentence_end(bytes[i]) && i + 1 < bytes.len() && bytes[i + 1].is_ascii_whitespace() {
1951            // Skip whitespace following the punctuation.
1952            let mut j = i + 2;
1953            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
1954                j += 1;
1955            }
1956            if j < from {
1957                return j;
1958            }
1959        }
1960        i -= 1;
1961    }
1962    0
1963}
1964
1965fn motion_sentence_forward(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
1966    let text = ctx.buffer.as_string();
1967    let bytes = text.as_bytes();
1968    let count = ctx.count.get().max(1);
1969    let mut idx = ctx
1970        .buffer
1971        .position_to_byte(ctx.from)
1972        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
1973    for _ in 0..count {
1974        match next_sentence_start(bytes, idx) {
1975            Some(next) => idx = next,
1976            None => {
1977                idx = bytes.len().saturating_sub(1);
1978                break;
1979            }
1980        }
1981    }
1982    let target = ctx
1983        .buffer
1984        .byte_to_position(idx)
1985        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
1986    Ok(MotionResult {
1987        curswant: None,
1988        target,
1989        linewise: false,
1990        exclusive: None,
1991        notice: None,
1992    })
1993}
1994
1995fn motion_sentence_backward(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
1996    let text = ctx.buffer.as_string();
1997    let bytes = text.as_bytes();
1998    let count = ctx.count.get().max(1);
1999    let mut idx = ctx
2000        .buffer
2001        .position_to_byte(ctx.from)
2002        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
2003    for _ in 0..count {
2004        let next = prev_sentence_start(bytes, idx);
2005        if next == idx {
2006            break;
2007        }
2008        idx = next;
2009    }
2010    let target = ctx
2011        .buffer
2012        .byte_to_position(idx)
2013        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
2014    Ok(MotionResult {
2015        curswant: None,
2016        target,
2017        linewise: false,
2018        exclusive: None,
2019        notice: None,
2020    })
2021}
2022
2023fn text_object_inner_sentence(ctx: &TextObjectContext) -> Result<ProtoRange, CommandError> {
2024    let text = ctx.buffer.as_string();
2025    let bytes = text.as_bytes();
2026    let cursor = ctx
2027        .buffer
2028        .position_to_byte(ctx.at)
2029        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
2030    let start_byte = prev_sentence_start(bytes, cursor.saturating_add(1));
2031    let mut end_byte = cursor;
2032    while end_byte < bytes.len() {
2033        if is_sentence_end(bytes[end_byte]) {
2034            // Inner stops at the punctuation (exclusive).
2035            break;
2036        }
2037        end_byte += 1;
2038    }
2039    let start_pos = ctx
2040        .buffer
2041        .byte_to_position(start_byte)
2042        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
2043    let end_pos = ctx
2044        .buffer
2045        .byte_to_position(end_byte)
2046        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
2047    Ok(ProtoRange::new(start_pos, end_pos))
2048}
2049
2050fn text_object_around_sentence(ctx: &TextObjectContext) -> Result<ProtoRange, CommandError> {
2051    let inner = text_object_inner_sentence(ctx)?;
2052    let text = ctx.buffer.as_string();
2053    let bytes = text.as_bytes();
2054    let inner_end_byte = ctx
2055        .buffer
2056        .position_to_byte(inner.end)
2057        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
2058    // Around: include the trailing punctuation + whitespace.
2059    let mut end = inner_end_byte;
2060    if end < bytes.len() && is_sentence_end(bytes[end]) {
2061        end += 1;
2062    }
2063    while end < bytes.len() && bytes[end].is_ascii_whitespace() {
2064        end += 1;
2065    }
2066    let end_pos = ctx
2067        .buffer
2068        .byte_to_position(end)
2069        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
2070    Ok(ProtoRange::new(inner.start, end_pos))
2071}
2072
2073// ---- WORD motions (vim's W, B, E) ----
2074
2075fn motion_big_word_forward(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2076    let text = ctx.buffer.as_string();
2077    let bytes = text.as_bytes();
2078    let total = bytes.len();
2079    let count = ctx.count.get().max(1);
2080    let mut idx = ctx
2081        .buffer
2082        .position_to_byte(ctx.from)
2083        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
2084
2085    for _ in 0..count {
2086        if idx >= total {
2087            break;
2088        }
2089        // Skip the current WORD's remaining non-whitespace bytes.
2090        while idx < total && is_big_word_byte(bytes[idx]) {
2091            idx += 1;
2092        }
2093        // Skip whitespace until the next WORD start.
2094        while idx < total && !is_big_word_byte(bytes[idx]) {
2095            idx += 1;
2096        }
2097    }
2098    if idx >= total {
2099        idx = total.saturating_sub(1);
2100    }
2101    let target = ctx
2102        .buffer
2103        .byte_to_position(idx)
2104        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
2105    Ok(MotionResult {
2106        curswant: None,
2107        target,
2108        linewise: false,
2109        exclusive: None,
2110        notice: None,
2111    })
2112}
2113
2114fn motion_big_word_backward(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2115    let text = ctx.buffer.as_string();
2116    let bytes = text.as_bytes();
2117    let count = ctx.count.get().max(1);
2118    let mut idx = ctx
2119        .buffer
2120        .position_to_byte(ctx.from)
2121        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
2122
2123    for _ in 0..count {
2124        if idx == 0 {
2125            break;
2126        }
2127        idx -= 1;
2128        while idx > 0 && !is_big_word_byte(bytes[idx]) {
2129            idx -= 1;
2130        }
2131        while idx > 0 && is_big_word_byte(bytes[idx - 1]) {
2132            idx -= 1;
2133        }
2134    }
2135    let target = ctx
2136        .buffer
2137        .byte_to_position(idx)
2138        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
2139    Ok(MotionResult {
2140        curswant: None,
2141        target,
2142        linewise: false,
2143        exclusive: None,
2144        notice: None,
2145    })
2146}
2147
2148fn motion_big_word_end(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2149    let text = ctx.buffer.as_string();
2150    let bytes = text.as_bytes();
2151    let total = bytes.len();
2152    let count = ctx.count.get().max(1);
2153    let mut idx = ctx
2154        .buffer
2155        .position_to_byte(ctx.from)
2156        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
2157
2158    for _ in 0..count {
2159        if idx >= total.saturating_sub(1) {
2160            break;
2161        }
2162        idx += 1;
2163        while idx < total && !is_big_word_byte(bytes[idx]) {
2164            idx += 1;
2165        }
2166        if idx >= total {
2167            idx = total.saturating_sub(1);
2168            break;
2169        }
2170        while idx + 1 < total && is_big_word_byte(bytes[idx + 1]) {
2171            idx += 1;
2172        }
2173    }
2174    let target = ctx
2175        .buffer
2176        .byte_to_position(idx)
2177        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
2178    Ok(MotionResult {
2179        curswant: None,
2180        target,
2181        linewise: false,
2182        exclusive: None,
2183        notice: None,
2184    })
2185}
2186
2187// ---- Motion: find-char / till-char (vim's f, F, t, T) ----
2188//
2189// Each takes `Args::Char(c)` and searches the current line. v1 ignores
2190// `count` (most users press `;` / `,` for repeat); count support lands
2191// later. Forward variants return the cursor's original line if the char
2192// is not found (matching vim's no-op behavior).
2193
2194fn args_to_char(args: &crate::args::Args) -> Result<char, CommandError> {
2195    match args {
2196        crate::args::Args::Char(c) => Ok(*c),
2197        _ => Err(CommandError::InvalidArgs("f/F/t/T require Args::Char")),
2198    }
2199}
2200
2201fn line_text(text: &str, line: u32) -> &str {
2202    text.split_inclusive('\n')
2203        .nth(line as usize)
2204        .map(|l| l.trim_end_matches('\n'))
2205        .unwrap_or("")
2206}
2207
2208fn motion_find_char_forward(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2209    let needle = args_to_char(&ctx.args)?;
2210    let text = ctx.buffer.as_string();
2211    let line = line_text(&text, ctx.from.line);
2212    let bytes = line.as_bytes();
2213    let start = (ctx.from.byte as usize).saturating_add(1);
2214    let mut buf = [0u8; 4];
2215    let needle_bytes = needle.encode_utf8(&mut buf).as_bytes();
2216    let nlen = needle_bytes.len();
2217    let mut idx = start;
2218    while idx + nlen <= bytes.len() {
2219        if &bytes[idx..idx + nlen] == needle_bytes {
2220            return Ok(MotionResult {
2221                curswant: None,
2222                target: Position::new(ctx.from.line, idx as u32),
2223                linewise: false,
2224                exclusive: None,
2225                notice: None,
2226            });
2227        }
2228        idx += 1;
2229    }
2230    // No match -- vim no-ops.
2231    Ok(MotionResult {
2232        curswant: None,
2233        target: ctx.from,
2234        linewise: false,
2235        exclusive: None,
2236        notice: None,
2237    })
2238}
2239
2240fn motion_find_char_backward(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2241    let needle = args_to_char(&ctx.args)?;
2242    let text = ctx.buffer.as_string();
2243    let line = line_text(&text, ctx.from.line);
2244    let bytes = line.as_bytes();
2245    let mut buf = [0u8; 4];
2246    let needle_bytes = needle.encode_utf8(&mut buf).as_bytes();
2247    let nlen = needle_bytes.len();
2248    if (ctx.from.byte as usize) < nlen {
2249        return Ok(MotionResult {
2250            curswant: None,
2251            target: ctx.from,
2252            linewise: false,
2253            exclusive: None,
2254            notice: None,
2255        });
2256    }
2257    let mut idx = (ctx.from.byte as usize) - nlen;
2258    loop {
2259        if idx + nlen <= bytes.len() && &bytes[idx..idx + nlen] == needle_bytes {
2260            return Ok(MotionResult {
2261                curswant: None,
2262                target: Position::new(ctx.from.line, idx as u32),
2263                linewise: false,
2264                exclusive: None,
2265                notice: None,
2266            });
2267        }
2268        if idx == 0 {
2269            break;
2270        }
2271        idx -= 1;
2272    }
2273    Ok(MotionResult {
2274        curswant: None,
2275        target: ctx.from,
2276        linewise: false,
2277        exclusive: None,
2278        notice: None,
2279    })
2280}
2281
2282fn motion_till_char_forward(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2283    // `t<c>`: identical to `f<c>` but the target is one byte before the match.
2284    let result = motion_find_char_forward(ctx)?;
2285    if result.target == ctx.from {
2286        return Ok(result);
2287    }
2288    let target_byte = result.target.byte.saturating_sub(1);
2289    Ok(MotionResult {
2290        curswant: None,
2291        target: Position::new(result.target.line, target_byte),
2292        linewise: false,
2293        exclusive: None,
2294        notice: None,
2295    })
2296}
2297
2298fn motion_till_char_backward(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2299    // `T<c>`: identical to `F<c>` but the target is one byte after the match.
2300    let result = motion_find_char_backward(ctx)?;
2301    if result.target == ctx.from {
2302        return Ok(result);
2303    }
2304    let line_len = result
2305        .target
2306        .byte
2307        .saturating_add(args_to_char(&ctx.args)?.len_utf8() as u32);
2308    Ok(MotionResult {
2309        curswant: None,
2310        target: Position::new(result.target.line, line_len),
2311        linewise: false,
2312        exclusive: None,
2313        notice: None,
2314    })
2315}
2316
2317// ---- Motions: find-repeat (vim's `;` and `,`) ----
2318
2319/// Repeat the last `f` / `F` / `t` / `T`, in the same direction.
2320///
2321/// VM.3c: a MOTION, not an action. Vim composes `;` (`d;` repeats the last
2322/// find under an operator) and extends a selection with it, and VM.1's
2323/// derivation only mirrors motions — so as an action it was unreachable from
2324/// both.
2325///
2326/// Delegates by rebuilding the context with `args` set to the remembered
2327/// character, rather than by re-implementing the four searches. That keeps one
2328/// implementation of "find a char on this line": a fix to `t`'s
2329/// one-byte-before rule lands in `;` for free, which is the property the old
2330/// host-side `do_find_repeat` had (it synthesised a `CommandInvocation`
2331/// against the find-char motion) and the one worth preserving across the move.
2332fn motion_find_repeat(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2333    find_repeat(ctx, false)
2334}
2335
2336/// `,` — repeat the last find in the OPPOSITE direction.
2337fn motion_find_repeat_reverse(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2338    find_repeat(ctx, true)
2339}
2340
2341fn find_repeat(ctx: &MotionContext, reverse: bool) -> Result<MotionResult, CommandError> {
2342    let Some(last) = ctx.last_find else {
2343        // No previous find. Vim bells; a motion that has nowhere to go
2344        // returns the cursor, so `d;` deletes nothing rather than something
2345        // arbitrary.
2346        return Ok(MotionResult {
2347            curswant: None,
2348            target: ctx.from,
2349            linewise: false,
2350            exclusive: None,
2351            notice: None,
2352        });
2353    };
2354    let kind = if reverse {
2355        last.kind.reversed()
2356    } else {
2357        last.kind
2358    };
2359    // Re-enter the real find with the remembered character in `args`. Built
2360    // field-by-field because `MotionContext` borrows and is neither `Clone`
2361    // nor constructible by struct-update from a reference.
2362    let sub = MotionContext {
2363        buffer: ctx.buffer,
2364        buffer_id: ctx.buffer_id,
2365        from: ctx.from,
2366        count: ctx.count,
2367        has_explicit_count: ctx.has_explicit_count,
2368        args: crate::args::Args::Char(last.target),
2369        cancel: ctx.cancel,
2370        scope_resolver: ctx.scope_resolver,
2371        path: ctx.path,
2372        syntax: ctx.syntax,
2373        last_find: ctx.last_find,
2374        fold_resolver: ctx.fold_resolver,
2375        last_search: ctx.last_search,
2376        marks: ctx.marks,
2377        viewport: ctx.viewport,
2378        nostartofline: ctx.nostartofline,
2379        curswant: ctx.curswant,
2380        display: ctx.display,
2381        scrolloff: ctx.scrolloff,
2382        operator_pending: ctx.operator_pending,
2383    };
2384    let mut result = match kind {
2385        FindKind::Forward => motion_find_char_forward(&sub)?,
2386        FindKind::Backward => motion_find_char_backward(&sub)?,
2387        FindKind::TillForward => motion_till_char_forward(&sub)?,
2388        FindKind::TillBackward => motion_till_char_backward(&sub)?,
2389    };
2390    // The whole reason `MotionResult::exclusive` exists. `;` and `,` take the
2391    // exclusivity of the motion they REPEAT, and the spec cannot know which
2392    // that is: `f` / `t` are inclusive, `F` / `T` are not. Without this,
2393    // `d,` after an `f` deleted one character too many.
2394    result.exclusive = Some(matches!(kind, FindKind::Backward | FindKind::TillBackward));
2395    Ok(result)
2396}
2397
2398// ---- Motion: first-non-blank (vim's `^`) ----
2399
2400fn motion_first_non_blank(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2401    let text = ctx.buffer.as_string();
2402    let line_text = text
2403        .split_inclusive('\n')
2404        .nth(ctx.from.line as usize)
2405        .map(|l| l.trim_end_matches('\n'))
2406        .unwrap_or("");
2407    let bytes = line_text.as_bytes();
2408    let mut col = 0usize;
2409    while col < bytes.len() && is_blank_byte(bytes[col]) {
2410        col += 1;
2411    }
2412    Ok(MotionResult {
2413        curswant: None,
2414        target: Position::new(ctx.from.line, col as u32),
2415        linewise: false,
2416        exclusive: None,
2417        notice: None,
2418    })
2419}
2420
2421// ---- Motion: char-left ----
2422
2423fn motion_char_left(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2424    let count = ctx.count.get().max(1);
2425    let mut pos = ctx.from;
2426    let text = ctx.buffer.as_string();
2427    let line = line_text(&text, pos.line);
2428    // Step by whole UTF-8 scalars, not bytes: a byte-step could land mid-glyph
2429    // (e.g. inside `│` U+2502, 3 bytes) and produce a delete range that panics
2430    // ropey on a non-char-boundary slice. `min` also snaps a mis-seeded
2431    // starting `pos.byte` onto the line before walking.
2432    let mut byte = (pos.byte as usize).min(line.len());
2433    for _ in 0..count {
2434        if byte == 0 {
2435            break;
2436        }
2437        byte -= 1;
2438        while byte > 0 && !line.is_char_boundary(byte) {
2439            byte -= 1;
2440        }
2441    }
2442    pos.byte = byte as u32;
2443    Ok(MotionResult {
2444        curswant: None,
2445        target: pos,
2446        linewise: false,
2447        exclusive: None,
2448        notice: None,
2449    })
2450}
2451
2452// ---- Motion: char-right ----
2453
2454fn motion_char_right(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2455    let count = ctx.count.get().max(1);
2456    let mut pos = ctx.from;
2457    let text = ctx.buffer.as_string();
2458    let line = line_text(&text, pos.line);
2459    let line_len = line.len();
2460    // Step by whole UTF-8 scalars, not bytes (see `motion_char_left`): a
2461    // byte-step over a multibyte glyph produced a delete range ending mid-char
2462    // and panicked ropey's `byte_slice` on the editor actor thread.
2463    let mut byte = (pos.byte as usize).min(line_len);
2464    for _ in 0..count {
2465        if byte >= line_len {
2466            break;
2467        }
2468        byte += 1;
2469        while byte < line_len && !line.is_char_boundary(byte) {
2470            byte += 1;
2471        }
2472    }
2473    pos.byte = byte as u32;
2474    Ok(MotionResult {
2475        curswant: None,
2476        target: pos,
2477        linewise: false,
2478        exclusive: None,
2479        notice: None,
2480    })
2481}
2482
2483// ---- Motion: line-up / line-down ----
2484
2485/// VM.3L: `k` is linewise (`dk` deletes two whole lines), and on the first
2486/// line it FAILS rather than returning the cursor. With linewise ranges, a
2487/// no-move result would make `dk` delete the current line; vim beeps and
2488/// deletes nothing. A count past the top clamps, as in vim (`5dk`).
2489fn motion_line_up(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2490    if ctx.from.line == 0 {
2491        return Err(CommandError::MotionFailed);
2492    }
2493    let count = ctx.count.get().max(1);
2494    let line = ctx.from.line.saturating_sub(count);
2495    let byte = curswant_byte(ctx, line);
2496    Ok(MotionResult {
2497        curswant: None,
2498        target: Position::new(line, byte),
2499        linewise: true,
2500        exclusive: None,
2501        notice: None,
2502    })
2503}
2504
2505/// VM.3L: `j` is linewise, and on the last line it fails; see `k` above.
2506/// vim 9.2: `dj` on the last line leaves the buffer and register untouched,
2507/// and `5dj` two lines from the end clamps to the end.
2508fn motion_line_down(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2509    let last = last_addressable_line(ctx.buffer);
2510    if ctx.from.line >= last {
2511        return Err(CommandError::MotionFailed);
2512    }
2513    let count = ctx.count.get().max(1);
2514    let line = ctx.from.line.saturating_add(count).min(last);
2515    let byte = curswant_byte(ctx, line);
2516    Ok(MotionResult {
2517        curswant: None,
2518        target: Position::new(line, byte),
2519        linewise: true,
2520        exclusive: None,
2521        notice: None,
2522    })
2523}
2524
2525// ---- Motion: line-start / line-end ----
2526
2527fn motion_line_start(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2528    Ok(MotionResult {
2529        curswant: None,
2530        target: Position::new(ctx.from.line, 0),
2531        linewise: false,
2532        exclusive: None,
2533        notice: None,
2534    })
2535}
2536
2537fn motion_line_end(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2538    let len = line_byte_len(ctx.buffer, ctx.from.line);
2539    Ok(MotionResult {
2540        curswant: None,
2541        target: Position::new(ctx.from.line, len),
2542        linewise: false,
2543        exclusive: None,
2544        notice: None,
2545    })
2546}
2547
2548// ---- Motion: goto-first-line / goto-last-line ----
2549
2550fn motion_goto_first_line(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2551    // `{count}gg` goes to line count (1-indexed); bare `gg` → line 1.
2552    let target_line = if ctx.has_explicit_count {
2553        let last = last_addressable_line(ctx.buffer);
2554        ctx.count.get().saturating_sub(1).min(last)
2555    } else {
2556        0
2557    };
2558    Ok(MotionResult {
2559        curswant: None,
2560        // VM.3j-1: the first non-blank, as vim does under `startofline`. This
2561        // was a hard column 0, which matched neither of vim's settings.
2562        target: startofline_target(ctx, target_line),
2563        linewise: true,
2564        exclusive: None,
2565        notice: None,
2566    })
2567}
2568
2569fn motion_goto_last_line(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2570    // `{count}G` goes to line count (1-indexed); bare `G` → last line.
2571    let last = last_addressable_line(ctx.buffer);
2572    let target_line = if ctx.has_explicit_count {
2573        ctx.count.get().saturating_sub(1).min(last)
2574    } else {
2575        last
2576    };
2577    Ok(MotionResult {
2578        curswant: None,
2579        // VM.3j-1: as `gg` above.
2580        target: startofline_target(ctx, target_line),
2581        linewise: true,
2582        exclusive: None,
2583        notice: None,
2584    })
2585}
2586
2587// ---- Helpers ----
2588
2589fn line_byte_len(buffer: &lattice_core::Buffer, line: u32) -> u32 {
2590    let s = buffer.as_string();
2591    s.split_inclusive('\n')
2592        .nth(line as usize)
2593        .map(|l| l.trim_end_matches('\n').len() as u32)
2594        .unwrap_or(0)
2595}
2596
2597/// VM.3m: `pos` brought inside the buffer it addresses — the line clamped to
2598/// the last one, the byte to that line's length. A yank's origin can point past
2599/// both after the operator ran.
2600fn clamp_into_buffer(buffer: &lattice_core::Buffer, pos: Position) -> Position {
2601    let line = pos.line.min(last_addressable_line(buffer));
2602    Position::new(line, pos.byte.min(line_byte_len(buffer, line)))
2603}
2604
2605/// VM.3g-2: the goal column WITHIN a display row. vim shares `curswant`
2606/// between `j` and `gj` (9.2: `j gj j` keeps it), so this is the same goal read
2607/// modulo the wrap width.
2608fn display_goal(ctx: &MotionContext, wrap: u32) -> u32 {
2609    let wrap = wrap.max(1);
2610    match ctx.curswant {
2611        Some(crate::registry::Curswant::EndOfLine) => wrap - 1,
2612        Some(crate::registry::Curswant::Col(c)) => c % wrap,
2613        None => ctx.from.byte % wrap,
2614    }
2615}
2616
2617/// VM.3g-2: vim's `gj` — one display ROW down, keeping the screen column.
2618/// With `wrap` off, or no renderer geometry, it IS `j`, as in vim.
2619///
2620/// Ported from `Editor::do_display_line_down` so the landing is unchanged; what
2621/// is new is that a count walks (vim's `2gj` moves two rows; the host action
2622/// ignored counts) and the goal column is the shared `curswant` rather than a
2623/// second, display-only one.
2624fn motion_display_line_down(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2625    let Some(display) = ctx.display else {
2626        return motion_line_down(ctx);
2627    };
2628    let wrap = display.wrap_width();
2629    if wrap == 0 {
2630        return motion_line_down(ctx);
2631    }
2632    let last = last_addressable_line(ctx.buffer);
2633    let goal = display_goal(ctx, wrap);
2634    let mut at = ctx.from;
2635    // VM.3g-3: the column the LAST step aimed at, before the row's length
2636    // clamped it. That, not the landing, is what vim records — see
2637    // `MotionResult::curswant`.
2638    let mut aim = at.byte;
2639    for _ in 0..ctx.count.get().max(1) {
2640        let seg = at.byte / wrap;
2641        let segs = display.segments(at.line).max(1);
2642        if seg + 1 < segs {
2643            // another row on this same line
2644            let next_start = (seg + 1) * wrap;
2645            let next_end = ((seg + 2) * wrap).min(line_byte_len(ctx.buffer, at.line));
2646            aim = next_start + goal;
2647            let byte = aim.min(next_end.saturating_sub(1).max(next_start));
2648            at = Position::new(at.line, byte);
2649        } else if at.line < last {
2650            let next = at.line + 1;
2651            let len = line_byte_len(ctx.buffer, next);
2652            aim = goal;
2653            at = Position::new(next, goal.min(len.saturating_sub(1)));
2654        } else {
2655            // vim: `gj` on the last display row stays put — and leaves the goal
2656            // alone with it, so a later `gj` onto a longer row still reaches it.
2657            break;
2658        }
2659    }
2660    Ok(MotionResult {
2661        curswant: Some(crate::registry::Curswant::Col(aim)),
2662        target: at,
2663        linewise: false,
2664        exclusive: None,
2665        notice: None,
2666    })
2667}
2668
2669/// VM.3g-2: vim's `gk`, the mirror of [`motion_display_line_down`].
2670fn motion_display_line_up(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2671    let Some(display) = ctx.display else {
2672        return motion_line_up(ctx);
2673    };
2674    let wrap = display.wrap_width();
2675    if wrap == 0 {
2676        return motion_line_up(ctx);
2677    }
2678    let goal = display_goal(ctx, wrap);
2679    let mut at = ctx.from;
2680    // VM.3g-3: as in `gj` — the aim, not the clamped landing.
2681    let mut aim = at.byte;
2682    for _ in 0..ctx.count.get().max(1) {
2683        let seg = at.byte / wrap;
2684        if seg > 0 {
2685            let prev_start = (seg - 1) * wrap;
2686            let actual_end = (seg * wrap).min(line_byte_len(ctx.buffer, at.line));
2687            aim = prev_start + goal;
2688            let byte = aim.min(actual_end.saturating_sub(1).max(prev_start));
2689            at = Position::new(at.line, byte);
2690        } else if at.line > 0 {
2691            let prev = at.line - 1;
2692            let len = line_byte_len(ctx.buffer, prev);
2693            let last_start = (display.segments(prev).max(1) - 1) * wrap;
2694            aim = last_start + goal;
2695            let byte = aim.min(len.saturating_sub(1).max(last_start));
2696            at = Position::new(prev, byte);
2697        } else {
2698            break;
2699        }
2700    }
2701    Ok(MotionResult {
2702        curswant: Some(crate::registry::Curswant::Col(aim)),
2703        target: at,
2704        linewise: false,
2705        exclusive: None,
2706        notice: None,
2707    })
2708}
2709
2710/// VM.3g-2: vim's `g0` — the first column of the display row. `0` without wrap.
2711fn motion_display_line_start(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2712    let Some(display) = ctx.display else {
2713        return motion_line_start(ctx);
2714    };
2715    let wrap = display.wrap_width();
2716    if wrap == 0 {
2717        return motion_line_start(ctx);
2718    }
2719    Ok(MotionResult {
2720        curswant: None,
2721        target: Position::new(ctx.from.line, (ctx.from.byte / wrap) * wrap),
2722        linewise: false,
2723        exclusive: None,
2724        notice: None,
2725    })
2726}
2727
2728/// VM.3g-2: vim's `g$` — the LAST column of the display row, and inclusive, so
2729/// `dg$` takes that column with it (9.2: `dg$` from 2,85 deletes 85–160).
2730/// `$` without wrap.
2731fn motion_display_line_end(ctx: &MotionContext) -> Result<MotionResult, CommandError> {
2732    let Some(display) = ctx.display else {
2733        return motion_line_end(ctx);
2734    };
2735    let wrap = display.wrap_width();
2736    if wrap == 0 {
2737        return motion_line_end(ctx);
2738    }
2739    let len = line_byte_len(ctx.buffer, ctx.from.line);
2740    let seg_end = (((ctx.from.byte / wrap) + 1) * wrap).min(len);
2741    Ok(MotionResult {
2742        curswant: None,
2743        target: Position::new(ctx.from.line, seg_end.saturating_sub(1)),
2744        linewise: false,
2745        exclusive: None,
2746        notice: None,
2747    })
2748}
2749
2750/// VM.3g-1: the column a vertical motion lands on in `line` — vim's
2751/// `curswant`, clamped to that line. With no goal recorded the cursor's own
2752/// column is the goal, which is what the first `j` after an edit does in vim.
2753///
2754/// The clamp is the whole reason the goal must be REMEMBERED rather than read
2755/// back off the cursor: `jj` from column 9 across a 2-column line has to return
2756/// to 9, and on the middle line the cursor is at 2.
2757fn curswant_byte(ctx: &MotionContext, line: u32) -> u32 {
2758    let len = line_byte_len(ctx.buffer, line);
2759    match ctx.curswant {
2760        Some(crate::registry::Curswant::EndOfLine) => len,
2761        Some(crate::registry::Curswant::Col(c)) => c.min(len),
2762        None => ctx.from.byte.min(len),
2763    }
2764}
2765
2766/// VM.3j-1: where a `startofline` jump lands on the line it reached — the
2767/// first non-blank, or the cursor's column when the user set `nostartofline`.
2768///
2769/// vim's rule for `gg` / `G` / `H` / `M` / `L` and the page scrolls, checked in
2770/// 9.2 (`vimcheck_sol_pages.vim`): `G` → 40,3 with `startofline` and 40,6 with
2771/// `nostartofline` on a 2-indented buffer. One function so the motions can't
2772/// drift apart — `H` / `M` / `L` (VM.3f) answered from their own copy of it.
2773fn startofline_target(ctx: &MotionContext, line: u32) -> Position {
2774    if ctx.nostartofline {
2775        let len = line_byte_len(ctx.buffer, line);
2776        Position::new(line, ctx.from.byte.min(len))
2777    } else {
2778        first_non_blank_position(ctx.buffer, line)
2779    }
2780}
2781
2782/// VM.3m: the first non-blank of `line`, where vim leaves the cursor after a
2783/// linewise delete (`dd`, `dk`, `2dj`).
2784fn first_non_blank_position(buffer: &lattice_core::Buffer, line: u32) -> Position {
2785    let text = buffer.line(line).unwrap_or_default();
2786    let col = text.bytes().take_while(|&b| is_blank_byte(b)).count() as u32;
2787    Position::new(line, col.min(line_byte_len(buffer, line)))
2788}
2789
2790/// CV.3: the last line a motion or range may address — content
2791/// space. One of three hand-rolled copies of this before the rename;
2792/// all three now sit on the accessor, and this one no longer
2793/// materialises the whole buffer with `as_string()` to ask whether it
2794/// ends in a newline.
2795fn last_addressable_line(buffer: &lattice_core::Buffer) -> u32 {
2796    buffer.content_line_count().saturating_sub(1)
2797}
2798
2799/// Extend a linewise content range to consume the trailing
2800/// newline so `dd` / `Ndd` reduces the buffer's line count by
2801/// `count` (vim semantics). When the range ends at the buffer's
2802/// last addressable line (no trailing newline available),
2803/// consume the LEADING newline before `start_line` instead --
2804/// otherwise the previous line would acquire a phantom newline.
2805/// A whole-buffer range (start at line 0 AND end at last line)
2806/// has no surrounding newline to claim and passes through
2807/// unchanged.
2808///
2809/// Used by `dd` (edit + slice). `yy` doesn't use this -- a
2810/// linewise yank's content always ends with `\n` regardless of
2811/// whether the source line had a trailing newline, so yank
2812/// appends `\n` to the original slice rather than walking the
2813/// buffer.
2814fn extend_linewise_range(buffer: &lattice_core::Buffer, range: ProtoRange) -> ProtoRange {
2815    let last = last_addressable_line(buffer);
2816    let end_line = range.end.line;
2817    if end_line < last {
2818        ProtoRange::new(range.start, Position::new(end_line + 1, 0))
2819    } else if range.start.line > 0 {
2820        let prev_line = range.start.line - 1;
2821        let prev_len = line_byte_len(buffer, prev_line);
2822        ProtoRange::new(Position::new(prev_line, prev_len), range.end)
2823    } else {
2824        range
2825    }
2826}
2827
2828// ---- Paragraph text objects (vim's `ip`, `ap`) ----
2829
2830fn text_object_inner_paragraph(ctx: &TextObjectContext) -> Result<ProtoRange, CommandError> {
2831    let text = ctx.buffer.as_string();
2832    let last_line = buffer_last_line(&text);
2833    let cursor_line = ctx.at.line.min(last_line);
2834    let blank_at_cursor = line_is_blank(&text, cursor_line);
2835    // Walk back to the start of the contiguous run-of-same-blank-status.
2836    let mut start = cursor_line;
2837    while start > 0 && line_is_blank(&text, start - 1) == blank_at_cursor {
2838        start -= 1;
2839    }
2840    // Walk forward to the end of the run.
2841    let mut end = cursor_line;
2842    while end < last_line && line_is_blank(&text, end + 1) == blank_at_cursor {
2843        end += 1;
2844    }
2845    let end_byte = line_byte_len(ctx.buffer, end);
2846    Ok(ProtoRange::new(
2847        Position::new(start, 0),
2848        Position::new(end, end_byte),
2849    ))
2850}
2851
2852fn text_object_around_paragraph(ctx: &TextObjectContext) -> Result<ProtoRange, CommandError> {
2853    // Inner paragraph + trailing blank lines (or leading if at end of buffer).
2854    let inner = text_object_inner_paragraph(ctx)?;
2855    let text = ctx.buffer.as_string();
2856    let last_line = buffer_last_line(&text);
2857    let mut end_line = inner.end.line;
2858    let blank_at_inner = line_is_blank(&text, end_line);
2859    if !blank_at_inner {
2860        while end_line < last_line && line_is_blank(&text, end_line + 1) {
2861            end_line += 1;
2862        }
2863        let end_byte = line_byte_len(ctx.buffer, end_line);
2864        Ok(ProtoRange::new(
2865            inner.start,
2866            Position::new(end_line, end_byte),
2867        ))
2868    } else {
2869        while end_line < last_line && !line_is_blank(&text, end_line + 1) {
2870            end_line += 1;
2871        }
2872        let end_byte = line_byte_len(ctx.buffer, end_line);
2873        Ok(ProtoRange::new(
2874            inner.start,
2875            Position::new(end_line, end_byte),
2876        ))
2877    }
2878}
2879
2880// ---- N.1.6: comment text objects (aC / iC) ----
2881//
2882// Commentstring-driven, NOT tree-sitter (works for any language with a
2883// known line-comment leader, even without a parse tree). `aC` = the
2884// contiguous run of full comment lines (markers included); `iC` = the
2885// comment text (the first line's leader stripped). The leader comes from
2886// `ctx.comment_syntax.line`, populated by the host from the active
2887// buffer's language. No leader (plain buffer / unknown language) or a
2888// cursor not on a comment line -> empty range -> the paired operator
2889// no-ops, matching vim's "daC with no comment does nothing".
2890
2891/// True when `line`'s first non-whitespace run starts with `leader`.
2892/// `///` and `//!` match because they begin with `//`.
2893fn line_is_comment(buffer: &lattice_core::Buffer, line: u32, leader: &str) -> bool {
2894    buffer
2895        .line(line)
2896        .map(|t| t.trim_start().starts_with(leader))
2897        .unwrap_or(false)
2898}
2899
2900/// Byte offset on `line` of the comment CONTENT: just past the leader
2901/// and one optional following space. `    // foo` (leader `//`) returns
2902/// the offset of `foo`.
2903fn comment_content_start(buffer: &lattice_core::Buffer, line: u32, leader: &str) -> u32 {
2904    let Some(text) = buffer.line(line) else {
2905        return 0;
2906    };
2907    let indent = text.len() - text.trim_start().len();
2908    let mut off = indent + leader.len();
2909    if text.as_bytes().get(off) == Some(&b' ') {
2910        off += 1;
2911    }
2912    off.min(text.len()) as u32
2913}
2914
2915/// Shared scan for `aC` / `iC`: expand over the contiguous run of full
2916/// comment lines containing the cursor. `aC` (`inner = false`) starts at
2917/// column 0; `iC` (`inner = true`) starts after the first line's leader.
2918/// Both end at the last comment line's content end (matching the
2919/// paragraph objects' linewise-ish shape).
2920fn comment_text_object(ctx: &TextObjectContext, inner: bool) -> Result<ProtoRange, CommandError> {
2921    let Some(leader) = ctx.comment_syntax.and_then(|c| c.line.as_deref()) else {
2922        return Ok(ProtoRange::new(ctx.at, ctx.at));
2923    };
2924    // CV.3: content space — the phantom line ropey reports after a
2925    // terminating newline is not a comment line and must not be
2926    // walked into.
2927    let line_count = ctx.buffer.content_line_count();
2928    if leader.is_empty() || line_count == 0 {
2929        return Ok(ProtoRange::new(ctx.at, ctx.at));
2930    }
2931    let cursor_line = ctx.at.line.min(line_count - 1);
2932    if !line_is_comment(ctx.buffer, cursor_line, leader) {
2933        return Ok(ProtoRange::new(ctx.at, ctx.at));
2934    }
2935    let mut start = cursor_line;
2936    while start > 0 && line_is_comment(ctx.buffer, start - 1, leader) {
2937        start -= 1;
2938    }
2939    let mut end = cursor_line;
2940    while end + 1 < line_count && line_is_comment(ctx.buffer, end + 1, leader) {
2941        end += 1;
2942    }
2943    let start_byte = if inner {
2944        comment_content_start(ctx.buffer, start, leader)
2945    } else {
2946        0
2947    };
2948    Ok(ProtoRange::new(
2949        Position::new(start, start_byte),
2950        Position::new(end, line_byte_len(ctx.buffer, end)),
2951    ))
2952}
2953
2954fn text_object_around_comment(ctx: &TextObjectContext) -> Result<ProtoRange, CommandError> {
2955    comment_text_object(ctx, false)
2956}
2957
2958fn text_object_inner_comment(ctx: &TextObjectContext) -> Result<ProtoRange, CommandError> {
2959    comment_text_object(ctx, true)
2960}
2961
2962// ---- Text objects ----
2963//
2964// Each text object computes a `ProtoRange` covering the relevant span
2965// at the cursor. Vim's text objects are inclusive of their ends; the
2966// dispatcher's [start, end) range is constructed to match by extending
2967// `end` past the last byte we want included.
2968
2969/// Inner-word run shared between `iw` and `iW`. The classifier decides the
2970/// run: `word_class` for `iw` (vim's 3-way Word / Punct / Blank, so `iw` on
2971/// a `.` selects the punctuation run and `iw` on a space selects the
2972/// whitespace run), `big_word_class` for `iW` (2-way NonBlank / Blank, so a
2973/// whole `foo.bar` is one WORD). The object is the maximal run of the
2974/// cursor's class -- every non-EOF byte belongs to some class, so (unlike the
2975/// old 2-way predicate) landing on punctuation or whitespace is never a no-op.
2976fn text_object_inner_word_class(
2977    ctx: &TextObjectContext,
2978    classify: fn(u8) -> WordClass,
2979) -> Result<ProtoRange, CommandError> {
2980    let text = ctx.buffer.as_string();
2981    let bytes = text.as_bytes();
2982    let cursor = ctx
2983        .buffer
2984        .position_to_byte(ctx.at)
2985        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
2986    if cursor >= bytes.len() {
2987        // Past EOF -- range is the cursor position alone.
2988        return Ok(ProtoRange::new(ctx.at, ctx.at));
2989    }
2990    let class = classify(bytes[cursor]);
2991    let mut start = cursor;
2992    while start > 0 && classify(bytes[start - 1]) == class {
2993        start -= 1;
2994    }
2995    let mut end = cursor;
2996    while end + 1 < bytes.len() && classify(bytes[end + 1]) == class {
2997        end += 1;
2998    }
2999    // [start, end] inclusive of word -> half-open is end + 1.
3000    let start_pos = ctx
3001        .buffer
3002        .byte_to_position(start)
3003        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3004    let end_pos = ctx
3005        .buffer
3006        .byte_to_position(end + 1)
3007        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3008    Ok(ProtoRange::new(start_pos, end_pos))
3009}
3010
3011/// Around-word run shared between `aw` and `aW`. Trailing whitespace
3012/// extension (or leading if none) is identical for both classes --
3013/// vim's `aw` and `aW` both stay on the line and use space/tab as
3014/// the whitespace to absorb.
3015fn text_object_around_word_class(
3016    ctx: &TextObjectContext,
3017    classify: fn(u8) -> WordClass,
3018) -> Result<ProtoRange, CommandError> {
3019    let inner = text_object_inner_word_class(ctx, classify)?;
3020    let text = ctx.buffer.as_string();
3021    let bytes = text.as_bytes();
3022    let inner_end_byte = ctx
3023        .buffer
3024        .position_to_byte(inner.end)
3025        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3026    let mut end = inner_end_byte;
3027    while end < bytes.len() && (bytes[end] == b' ' || bytes[end] == b'\t') {
3028        end += 1;
3029    }
3030    if end == inner_end_byte {
3031        // No trailing whitespace -- try leading.
3032        let inner_start_byte = ctx
3033            .buffer
3034            .position_to_byte(inner.start)
3035            .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3036        let mut start = inner_start_byte;
3037        while start > 0 && (bytes[start - 1] == b' ' || bytes[start - 1] == b'\t') {
3038            start -= 1;
3039        }
3040        let start_pos = ctx
3041            .buffer
3042            .byte_to_position(start)
3043            .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3044        Ok(ProtoRange::new(start_pos, inner.end))
3045    } else {
3046        let end_pos = ctx
3047            .buffer
3048            .byte_to_position(end)
3049            .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3050        Ok(ProtoRange::new(inner.start, end_pos))
3051    }
3052}
3053
3054fn text_object_inner_word(ctx: &TextObjectContext) -> Result<ProtoRange, CommandError> {
3055    text_object_inner_word_class(ctx, word_class)
3056}
3057
3058fn text_object_around_word(ctx: &TextObjectContext) -> Result<ProtoRange, CommandError> {
3059    text_object_around_word_class(ctx, word_class)
3060}
3061
3062fn text_object_inner_big_word(ctx: &TextObjectContext) -> Result<ProtoRange, CommandError> {
3063    text_object_inner_word_class(ctx, big_word_class)
3064}
3065
3066fn text_object_around_big_word(ctx: &TextObjectContext) -> Result<ProtoRange, CommandError> {
3067    text_object_around_word_class(ctx, big_word_class)
3068}
3069
3070fn find_quote_pair(bytes: &[u8], cursor: usize, q: u8) -> Option<(usize, usize)> {
3071    // Restrict to the current line: walk back to start-of-line, then
3072    // forward, collecting quote positions; pair them naively (no escape
3073    // handling for v1).
3074    let mut line_start = cursor;
3075    while line_start > 0 && bytes[line_start - 1] != b'\n' {
3076        line_start -= 1;
3077    }
3078    let mut line_end = cursor;
3079    while line_end < bytes.len() && bytes[line_end] != b'\n' {
3080        line_end += 1;
3081    }
3082    let mut quotes = Vec::new();
3083    for (offset, &b) in bytes[line_start..line_end].iter().enumerate() {
3084        if b == q {
3085            quotes.push(line_start + offset);
3086        }
3087    }
3088    // Find a pair where the cursor is inside (or on a quote).
3089    let mut i = 0;
3090    while i + 1 < quotes.len() {
3091        let (l, r) = (quotes[i], quotes[i + 1]);
3092        if cursor >= l && cursor <= r {
3093            return Some((l, r));
3094        }
3095        i += 2;
3096    }
3097    None
3098}
3099
3100fn text_object_inner_quote(ctx: &TextObjectContext, q: char) -> Result<ProtoRange, CommandError> {
3101    let text = ctx.buffer.as_string();
3102    let bytes = text.as_bytes();
3103    let cursor = ctx
3104        .buffer
3105        .position_to_byte(ctx.at)
3106        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3107    if !q.is_ascii() {
3108        return Err(CommandError::InvalidArgs("non-ASCII quote not supported"));
3109    }
3110    let q_byte = q as u8;
3111    let Some((l, r)) = find_quote_pair(bytes, cursor, q_byte) else {
3112        return Ok(ProtoRange::new(ctx.at, ctx.at));
3113    };
3114    // Inner: between the quotes, exclusive of both.
3115    let start_pos = ctx
3116        .buffer
3117        .byte_to_position(l + 1)
3118        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3119    let end_pos = ctx
3120        .buffer
3121        .byte_to_position(r)
3122        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3123    Ok(ProtoRange::new(start_pos, end_pos))
3124}
3125
3126fn text_object_around_quote(ctx: &TextObjectContext, q: char) -> Result<ProtoRange, CommandError> {
3127    let text = ctx.buffer.as_string();
3128    let bytes = text.as_bytes();
3129    let cursor = ctx
3130        .buffer
3131        .position_to_byte(ctx.at)
3132        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3133    if !q.is_ascii() {
3134        return Err(CommandError::InvalidArgs("non-ASCII quote not supported"));
3135    }
3136    let q_byte = q as u8;
3137    let Some((l, r)) = find_quote_pair(bytes, cursor, q_byte) else {
3138        return Ok(ProtoRange::new(ctx.at, ctx.at));
3139    };
3140    let start_pos = ctx
3141        .buffer
3142        .byte_to_position(l)
3143        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3144    let end_pos = ctx
3145        .buffer
3146        .byte_to_position(r + 1)
3147        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3148    Ok(ProtoRange::new(start_pos, end_pos))
3149}
3150
3151fn find_bracket_pair(bytes: &[u8], cursor: usize, open: u8, close: u8) -> Option<(usize, usize)> {
3152    if bytes.is_empty() {
3153        return None;
3154    }
3155    // Walk left to find the most recent unmatched `open`.
3156    let mut depth = 0i32;
3157    let mut i = cursor.min(bytes.len() - 1);
3158    let open_pos = loop {
3159        let b = bytes[i];
3160        if b == close && i != cursor {
3161            // Same-position cursor on a close-bracket counts as "inside",
3162            // not as an additional level.
3163            depth += 1;
3164        } else if b == open {
3165            if depth == 0 {
3166                break i;
3167            }
3168            depth -= 1;
3169        }
3170        if i == 0 {
3171            return None;
3172        }
3173        i -= 1;
3174    };
3175    // Walk right from open_pos+1 to find the matching close.
3176    let mut depth = 0i32;
3177    let mut j = open_pos + 1;
3178    while j < bytes.len() {
3179        let b = bytes[j];
3180        if b == open {
3181            depth += 1;
3182        } else if b == close {
3183            if depth == 0 {
3184                return Some((open_pos, j));
3185            }
3186            depth -= 1;
3187        }
3188        j += 1;
3189    }
3190    None
3191}
3192
3193fn text_object_inner_brackets(
3194    ctx: &TextObjectContext,
3195    open: char,
3196    close: char,
3197) -> Result<ProtoRange, CommandError> {
3198    let text = ctx.buffer.as_string();
3199    let bytes = text.as_bytes();
3200    let cursor = ctx
3201        .buffer
3202        .position_to_byte(ctx.at)
3203        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3204    let Some((l, r)) = find_bracket_pair(bytes, cursor, open as u8, close as u8) else {
3205        return Ok(ProtoRange::new(ctx.at, ctx.at));
3206    };
3207    let start_pos = ctx
3208        .buffer
3209        .byte_to_position(l + 1)
3210        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3211    let end_pos = ctx
3212        .buffer
3213        .byte_to_position(r)
3214        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3215    Ok(ProtoRange::new(start_pos, end_pos))
3216}
3217
3218/// Find the nearest XML/HTML tag pair enclosing the cursor. Returns
3219/// `(open_start, open_end, close_start, close_end)` byte offsets where
3220/// `open_*` covers `<tag...>` and `close_*` covers `</tag>`. v1 only
3221/// supports same-line tag pairs and ignores attributes.
3222fn find_enclosing_tag(bytes: &[u8], cursor: usize) -> Option<(usize, usize, usize, usize)> {
3223    // Walk back to the nearest unmatched '<...>' open tag.
3224    let mut i = cursor.min(bytes.len().saturating_sub(1));
3225    let mut depth = 0i32;
3226    let open_start;
3227    loop {
3228        if bytes[i] == b'>' && i > 0 && bytes[i - 1] != b'/' {
3229            // a `>` closing some tag (open or self-close)
3230            // We want to track whether it's a close-tag or open-tag.
3231            // For simplicity we just look back at the matching `<`.
3232            // Skip for now; the depth tracking happens at `<`.
3233        }
3234        if bytes[i] == b'<' {
3235            // Look ahead to determine if this is open or close tag.
3236            if i + 1 < bytes.len() && bytes[i + 1] == b'/' {
3237                depth += 1;
3238            } else if depth == 0 {
3239                open_start = i;
3240                break;
3241            } else {
3242                depth -= 1;
3243            }
3244        }
3245        if i == 0 {
3246            return None;
3247        }
3248        i -= 1;
3249    }
3250    // Find the end of the open tag.
3251    let mut open_end = open_start + 1;
3252    while open_end < bytes.len() && bytes[open_end] != b'>' {
3253        open_end += 1;
3254    }
3255    if open_end >= bytes.len() {
3256        return None;
3257    }
3258    // Extract tag name.
3259    let mut name_end = open_start + 1;
3260    while name_end < open_end
3261        && bytes[name_end] != b' '
3262        && bytes[name_end] != b'>'
3263        && bytes[name_end] != b'/'
3264    {
3265        name_end += 1;
3266    }
3267    let name = &bytes[open_start + 1..name_end];
3268    if name.is_empty() {
3269        return None;
3270    }
3271    // Walk forward from `open_end + 1` to find matching `</name>`.
3272    let close_marker: Vec<u8> = {
3273        let mut v = Vec::with_capacity(name.len() + 3);
3274        v.push(b'<');
3275        v.push(b'/');
3276        v.extend_from_slice(name);
3277        v.push(b'>');
3278        v
3279    };
3280    let mut j = open_end + 1;
3281    while j + close_marker.len() <= bytes.len() {
3282        if &bytes[j..j + close_marker.len()] == close_marker.as_slice() {
3283            return Some((open_start, open_end + 1, j, j + close_marker.len()));
3284        }
3285        j += 1;
3286    }
3287    None
3288}
3289
3290fn text_object_inner_tag(ctx: &TextObjectContext) -> Result<ProtoRange, CommandError> {
3291    let text = ctx.buffer.as_string();
3292    let bytes = text.as_bytes();
3293    let cursor = ctx
3294        .buffer
3295        .position_to_byte(ctx.at)
3296        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3297    let Some((_, open_end, close_start, _)) = find_enclosing_tag(bytes, cursor) else {
3298        return Ok(ProtoRange::new(ctx.at, ctx.at));
3299    };
3300    let start_pos = ctx
3301        .buffer
3302        .byte_to_position(open_end)
3303        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3304    let end_pos = ctx
3305        .buffer
3306        .byte_to_position(close_start)
3307        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3308    Ok(ProtoRange::new(start_pos, end_pos))
3309}
3310
3311fn text_object_around_tag(ctx: &TextObjectContext) -> Result<ProtoRange, CommandError> {
3312    let text = ctx.buffer.as_string();
3313    let bytes = text.as_bytes();
3314    let cursor = ctx
3315        .buffer
3316        .position_to_byte(ctx.at)
3317        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3318    let Some((open_start, _, _, close_end)) = find_enclosing_tag(bytes, cursor) else {
3319        return Ok(ProtoRange::new(ctx.at, ctx.at));
3320    };
3321    let start_pos = ctx
3322        .buffer
3323        .byte_to_position(open_start)
3324        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3325    let end_pos = ctx
3326        .buffer
3327        .byte_to_position(close_end)
3328        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3329    Ok(ProtoRange::new(start_pos, end_pos))
3330}
3331
3332fn text_object_around_brackets(
3333    ctx: &TextObjectContext,
3334    open: char,
3335    close: char,
3336) -> Result<ProtoRange, CommandError> {
3337    let text = ctx.buffer.as_string();
3338    let bytes = text.as_bytes();
3339    let cursor = ctx
3340        .buffer
3341        .position_to_byte(ctx.at)
3342        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3343    let Some((l, r)) = find_bracket_pair(bytes, cursor, open as u8, close as u8) else {
3344        return Ok(ProtoRange::new(ctx.at, ctx.at));
3345    };
3346    let start_pos = ctx
3347        .buffer
3348        .byte_to_position(l)
3349        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3350    let end_pos = ctx
3351        .buffer
3352        .byte_to_position(r + 1)
3353        .map_err(|_| CommandError::InvalidArgs("position out of bounds"))?;
3354    Ok(ProtoRange::new(start_pos, end_pos))
3355}
3356
3357// ---- Operator: delete ----
3358//
3359// Vim's `d` -- delete the bytes covered by the target range. Also yanks
3360// the deleted content into the unnamed register (vim's behavior). The
3361// returned Effect is a `Many` so callers see both the buffer mutation
3362// and the yank in a single dispatched commit.
3363
3364fn operator_delete(ctx: &mut OperatorContext) -> Result<Effect, CommandError> {
3365    if ctx.range.is_empty() {
3366        return Ok(Effect::None);
3367    }
3368    // Slice 8.i.4.g: vim's `dd` / `Ndd` consumes the line(s) AND
3369    // the trailing newline so the line count drops by `count`.
3370    // Pure-content delete (charwise / non-linewise visual)
3371    // continues to leave the line structure intact, matching
3372    // `dw` / `d$` semantics.
3373    let edit_range = if ctx.linewise {
3374        extend_linewise_range(ctx.document.buffer(), ctx.range)
3375    } else {
3376        ctx.range
3377    };
3378    // Yank content always ends with `\n` for linewise -- the
3379    // register kind drives paste behaviour, but the content
3380    // string itself follows vim's clipboard convention. The
3381    // edit consumes the same range, so this keeps yank-on-
3382    // delete and the actual edit aligned.
3383    let yanked = if ctx.linewise {
3384        let mut s = ctx.document.buffer().slice(ctx.range)?;
3385        if !s.ends_with('\n') {
3386            s.push('\n');
3387        }
3388        s
3389    } else {
3390        ctx.document.buffer().slice(ctx.range)?
3391    };
3392    let edit = Edit::delete(edit_range);
3393    let applied = ctx.document.apply_edit(edit)?;
3394    let yank_kind = if ctx.linewise {
3395        YankKind::Linewise
3396    } else {
3397        YankKind::Charwise
3398    };
3399    let mut effects = vec![
3400        Effect::Edits(vec![applied]),
3401        Effect::Yank {
3402            register: ctx.register,
3403            content: yanked,
3404            kind: yank_kind,
3405            // Delete populates registers but is NOT an explicit yank —
3406            // it must not mirror to the system clipboard (yank-only rule).
3407            explicit_yank: false,
3408        },
3409    ];
3410    // VM.3m: after a LINEWISE delete vim puts the cursor on the first non-blank
3411    // of the line that moved up (`dd` from 1,6 lands on 1,5 of a 4-indented
3412    // line; `dk` and `2dj` likewise). A charwise delete leaves it at the edit's
3413    // start, which the host already does from `Effect::Edits`, so only the
3414    // linewise case needs saying. The buffer here is the one after the edit.
3415    if ctx.linewise {
3416        let buffer = ctx.document.buffer();
3417        let line = ctx.range.start.line.min(last_addressable_line(buffer));
3418        effects.push(Effect::CursorMove(first_non_blank_position(buffer, line)));
3419    }
3420    Ok(Effect::Many(effects))
3421}
3422
3423// ---- Operator: change ----
3424//
3425// Vim's `c` -- delete the target range, yank it (matching delete's
3426// behavior), and enter Insert mode at the deleted position. Composition
3427// expressed in the Effect, not a flag on the spec.
3428
3429fn operator_change(ctx: &mut OperatorContext) -> Result<Effect, CommandError> {
3430    if ctx.range.is_empty() {
3431        // No-op change still drops into Insert (vim does this for `cw` on
3432        // an empty buffer / past EOF).
3433        return Ok(Effect::EnterMode(crate::modal::ModalState::Insert));
3434    }
3435    let yanked = ctx.document.buffer().slice(ctx.range)?;
3436    let edit = Edit::delete(ctx.range);
3437    let applied = ctx.document.apply_edit(edit)?;
3438    let yank_kind = if ctx.linewise {
3439        YankKind::Linewise
3440    } else {
3441        YankKind::Charwise
3442    };
3443    Ok(Effect::Many(vec![
3444        Effect::Edits(vec![applied]),
3445        Effect::Yank {
3446            register: ctx.register,
3447            content: yanked,
3448            kind: yank_kind,
3449            // Change deletes + yanks the range like delete — not an
3450            // explicit yank, so no clipboard mirror (yank-only rule).
3451            explicit_yank: false,
3452        },
3453        Effect::EnterMode(crate::modal::ModalState::Insert),
3454    ]))
3455}
3456
3457// ---- Operator: replace-char (vim's `r{char}` and Visual `r`) ----
3458//
3459// Overwrite each non-newline char in the target range with the captured
3460// replacement char, producing one `Effect::Edits` -- so both renderers
3461// pick it up through the standard edit path with no per-renderer arm.
3462//
3463// One body serves both entry points:
3464//
3465// * Normal `Nr{char}`: the keymap binds the range to `char_right x count`
3466//   (pre-clamped to the current line, exactly as `x` = delete+char_right).
3467//   Vim's "no-op when fewer than N chars remain" falls out of the
3468//   `n_chars < count` guard -- the clamped range simply holds fewer chars
3469//   than the requested count. Stays in Normal.
3470// * Visual `r{char}`: dispatched with `Range::Selection` (count defaults
3471//   to 1), so the guard degenerates to "no-op on an empty span" and every
3472//   selected char is overwritten. Blockwise routes per-row via
3473//   `blockwise_per_row`. The host auto-exits Visual after any operator
3474//   (like `d` / `c` / `y`), so no explicit mode transition is emitted.
3475//
3476// Newlines inside the range are preserved, so a charwise / linewise
3477// multi-line selection keeps its line structure (vim replaces characters,
3478// never line breaks).
3479fn operator_replace_char(ctx: &mut OperatorContext) -> Result<Effect, CommandError> {
3480    let replacement = match &ctx.args {
3481        crate::args::Args::Char(c) => *c,
3482        _ => return Err(CommandError::InvalidArgs("r requires Args::Char")),
3483    };
3484    if ctx.range.is_empty() {
3485        return Ok(Effect::None);
3486    }
3487    let text = ctx.document.buffer().slice(ctx.range)?;
3488    let n_chars = text.chars().filter(|c| *c != '\n').count() as u32;
3489    // Vim's `Nr{char}` requires N characters on the line; a too-large
3490    // count is a no-op (vim bells). For Visual the count is 1, so any
3491    // non-empty span replaces.
3492    if n_chars < ctx.count.get().max(1) {
3493        return Ok(Effect::None);
3494    }
3495    let replaced: String = text
3496        .chars()
3497        .map(|c| if c == '\n' { '\n' } else { replacement })
3498        .collect();
3499    let applied = ctx
3500        .document
3501        .apply_edit(Edit::replace(ctx.range, replaced))?;
3502    Ok(Effect::Edits(vec![applied]))
3503}
3504
3505// ---- Operator: yank ----
3506//
3507// Vim's `y` -- copy the target range into the unnamed register without
3508// touching the buffer.
3509
3510fn operator_yank(ctx: &mut OperatorContext) -> Result<Effect, CommandError> {
3511    if ctx.range.is_empty() {
3512        return Ok(Effect::None);
3513    }
3514    // Slice 8.i.4.g: vim's `yy` / `Nyy` yanks line(s) WITH the
3515    // trailing newline so the register content can be pasted
3516    // linewise without splicing newlines in by hand. Append `\n`
3517    // when missing rather than walking the buffer for the
3518    // trailing newline -- linewise yank on the last line still
3519    // gets a trailing `\n` to match the canonical convention.
3520    let content = if ctx.linewise {
3521        let mut s = ctx.document.buffer().slice(ctx.range)?;
3522        if !s.ends_with('\n') {
3523            s.push('\n');
3524        }
3525        s
3526    } else {
3527        ctx.document.buffer().slice(ctx.range)?
3528    };
3529    let kind = if ctx.linewise {
3530        YankKind::Linewise
3531    } else {
3532        YankKind::Charwise
3533    };
3534    // Yank ALSO populates `"0` per vim semantics. Two Effect::Yanks --
3535    // first the requested register (which the App layer's store_yank
3536    // also mirrors into the unnamed register), then "0.
3537    Ok(Effect::Many(vec![
3538        Effect::Yank {
3539            register: ctx.register,
3540            content: content.clone(),
3541            kind,
3542            // The one explicit-yank write: eligible for the clipboard
3543            // mirror under the `clipboard` option.
3544            explicit_yank: true,
3545        },
3546        Effect::Yank {
3547            register: crate::register::Register::Numbered(0),
3548            content,
3549            kind,
3550            // The `"0` mirror is a register-only bookkeeping write; the
3551            // primary write above already handled the clipboard.
3552            explicit_yank: false,
3553        },
3554        // VM.3m: vim leaves the cursor at the start of what was yanked — which
3555        // for `yy` / `2yy` / `yj` is where it already was, and for `yk` / `yb` /
3556        // `y{` / `yip` is the start of the region. `origin` is that start before
3557        // linewise expansion, so `yk` keeps its column.
3558        Effect::CursorMove(clamp_into_buffer(ctx.document.buffer(), ctx.origin)),
3559    ]))
3560}
3561
3562// ---- Indent operators (>, <) ----
3563
3564/// Shared body of `>` and `<`: shift every line in the range by
3565/// `levels` indent steps.
3566///
3567/// IN.0 replaced a hardcoded `"    "` with `ctx.indent`, which changes
3568/// the model as well as the width. Indentation is now measured in
3569/// **display columns** and the whole leading-whitespace run is
3570/// re-rendered, rather than bytes being spliced on or off the front:
3571///
3572/// - a tab and `tabstop` spaces are the same indent and must shift
3573///   identically -- a byte splice cannot see that;
3574/// - `expandtab` decides what the *result* is made of, so a shifted
3575///   line has to be re-rendered, not patched;
3576/// - a `shiftwidth` that is not a multiple of `tabstop` is only
3577///   representable if the prefix can be rebuilt as tabs-plus-remainder.
3578///
3579/// Vim behaves the same way -- `>>` normalises the indent it touches.
3580///
3581/// The whole operation lands as a single undo unit: the per-line edits
3582/// are built up front and committed via `apply_edit_batch`, so `2>>` or
3583/// a visual `>` over N lines is one `u` away from undone, not N.
3584fn shift_indent(ctx: &mut OperatorContext, levels: i32) -> Result<Effect, CommandError> {
3585    if ctx.range.is_empty() {
3586        return Ok(Effect::None);
3587    }
3588    let first_line = ctx.range.start.line;
3589    let last_line = ctx.range.end.line;
3590    let buffer_text = ctx.document.text();
3591    let lines: Vec<&str> = buffer_text.split_inclusive('\n').collect();
3592
3593    // Bottom-up so earlier edits don't shift later positions when the
3594    // buffer applies the batch in order.
3595    let mut edits: Vec<Edit> = Vec::new();
3596    for line in (first_line..=last_line).rev() {
3597        let line_text = lines
3598            .get(line as usize)
3599            .map(|l| l.trim_end_matches('\n'))
3600            .unwrap_or("");
3601        // `None` covers a blank line (vim leaves those alone rather
3602        // than parking trailing whitespace on a line the user never
3603        // typed into) and a shift whose rendering is byte-identical to
3604        // what is already there (a no-op edit still costs an undo entry
3605        // and a render invalidation).
3606        let Some((old_len, rendered)) = ctx.indent.reindented_prefix(line_text, levels) else {
3607            continue;
3608        };
3609        let range = lattice_protocol::position::Range::new(
3610            Position::new(line, 0),
3611            Position::new(line, old_len as u32),
3612        );
3613        edits.push(Edit::replace(range, rendered));
3614    }
3615    if edits.is_empty() {
3616        return Ok(Effect::None);
3617    }
3618    let applied = ctx.document.apply_edit_batch(edits)?;
3619    // Restore top-down ordering for the returned AppliedEdits so
3620    // downstream `handle_edits` lands the cursor on the topmost
3621    // line, matching the previous one-edit-at-a-time behavior.
3622    let mut applied = applied;
3623    applied.reverse();
3624    Ok(landing_on_first_non_blank(ctx, first_line, applied))
3625}
3626
3627/// Vim leaves the cursor on the first non-blank of the FIRST line a
3628/// whole-line operator touched (`:h >`), which is where `>>`, `<ip` and `=ap`
3629/// all end up. The edits alone land it at the edit's start — column 0 of the
3630/// topmost line — so the move is appended, exactly as a linewise delete does.
3631///
3632/// Read AFTER the edits: the indent that decides "first non-blank" is the one
3633/// the operator just wrote, not the one it replaced.
3634fn landing_on_first_non_blank(
3635    ctx: &mut OperatorContext,
3636    first_line: u32,
3637    applied: Vec<lattice_core::buffer::AppliedEdit>,
3638) -> Effect {
3639    let buffer = ctx.document.buffer();
3640    let line = first_line.min(last_addressable_line(buffer));
3641    let at = first_non_blank_position(buffer, line);
3642    Effect::Many(vec![Effect::Edits(applied), Effect::CursorMove(at)])
3643}
3644
3645/// Vim's `>` -- add one indent level to each line in the range.
3646fn operator_indent_right(ctx: &mut OperatorContext) -> Result<Effect, CommandError> {
3647    shift_indent(ctx, 1)
3648}
3649
3650/// Vim's `<` -- remove one indent level from each line in the range,
3651/// clamped at column 0.
3652fn operator_indent_left(ctx: &mut OperatorContext) -> Result<Effect, CommandError> {
3653    shift_indent(ctx, -1)
3654}
3655
3656/// Vim's `=` -- reindent each line in the range to the depth the
3657/// structure says it should have.
3658///
3659/// **Indent-only, and that is the whole design decision** (auto-indent.md
3660/// §7). `=` adjusts leading whitespace and touches nothing else. It is
3661/// deliberately NOT wired to a formatter: LSP `rangeFormatting`, rustfmt
3662/// and prettier all *reformat* — they move line breaks and rewrite
3663/// spacing — so `=ap` would mean "rewrite this paragraph" rather than
3664/// "reindent it". An operator whose effect is unbounded rewriting cannot
3665/// be composed with motions safely, which is what makes the vim grammar
3666/// work at all. Full reformatting lives on `:format`.
3667///
3668/// Lines the resolver has no answer for are **left untouched** rather
3669/// than guessed at, so `=` over a range containing a syntax error or a
3670/// heredoc reindents what it understands and does not mangle the rest.
3671///
3672/// One undo unit for the whole range, like its `>` / `<` siblings.
3673fn operator_reindent(ctx: &mut OperatorContext) -> Result<Effect, CommandError> {
3674    if ctx.range.is_empty() {
3675        return Ok(Effect::None);
3676    }
3677    // RF.5b: `format.indent` names a non-native provider. Checked BEFORE
3678    // the resolver, because a chain pointing at an external indenter must
3679    // work on a buffer with no tree — that is most of why someone would
3680    // configure one.
3681    if !ctx.native_format.indent {
3682        return Ok(Effect::AppAction(
3683            crate::app_effect::AppEffect::FormatRange {
3684                intent: lattice_core::FormatIntent::Indent,
3685                start_line: ctx.range.start.line,
3686                end_line: ctx.range.end.line,
3687            },
3688        ));
3689    }
3690    let Some(resolver) = ctx.indent_resolver else {
3691        // No structural source: `=` is a no-op rather than a guess.
3692        // Falling back to the lexical bridge here would be wrong --
3693        // that bridge answers "where would a NEW line go", which is a
3694        // different question from "where does this EXISTING line
3695        // belong", and using it would drift a whole range toward
3696        // whatever the first line happened to have.
3697        return Ok(Effect::None);
3698    };
3699    let first_line = ctx.range.start.line;
3700    let last_line = ctx.range.end.line;
3701    let buffer_text = ctx.document.text();
3702    let lines: Vec<&str> = buffer_text.split_inclusive('\n').collect();
3703
3704    // Bottom-up so earlier edits do not shift later positions when the
3705    // batch applies in order.
3706    let mut edits: Vec<Edit> = Vec::new();
3707    for line in (first_line..=last_line).rev() {
3708        ctx.cancel.check()?;
3709        let line_text = lines
3710            .get(line as usize)
3711            .map(|l| l.trim_end_matches('\n'))
3712            .unwrap_or("");
3713        // Blank lines carry no structure to align; reindenting them
3714        // would only add trailing whitespace.
3715        if lattice_core::IndentUnit::is_blank(line_text) {
3716            continue;
3717        }
3718        let Some(levels) = resolver.levels_for_line(line) else {
3719            continue;
3720        };
3721        let target = ctx.indent.shift(0, levels);
3722        let rendered = ctx.indent.render(target);
3723        let old_len = lattice_core::IndentUnit::indent_len(line_text);
3724        if rendered.as_bytes() == &line_text.as_bytes()[..old_len] {
3725            continue;
3726        }
3727        let range = lattice_protocol::position::Range::new(
3728            Position::new(line, 0),
3729            Position::new(line, old_len as u32),
3730        );
3731        edits.push(Edit::replace(range, rendered));
3732    }
3733    if edits.is_empty() {
3734        return Ok(Effect::None);
3735    }
3736    let applied = ctx.document.apply_edit_batch(edits)?;
3737    let mut applied = applied;
3738    applied.reverse();
3739    Ok(landing_on_first_non_blank(ctx, first_line, applied))
3740}
3741
3742/// Vim's `gq` **and** `gw` — reflow the range to `textwidth`.
3743///
3744/// One operator behind two chords. In vim they differ only in cursor
3745/// placement (`gq` leaves it on the last formatted line, `gw` restores
3746/// it), which is two mnemonics for one operation and a reliable source
3747/// of "which one was it again". This preserves the cursor — `gw`'s
3748/// behaviour, because it is the one people actually want — and binds
3749/// both chords to it. Zed's vim keymap made the identical call.
3750///
3751/// **Reflow, not reformat.** It moves line breaks and normalises
3752/// interior whitespace within a paragraph; it does not reindent, reorder
3753/// or parse. That is what keeps it composable with motions, the same
3754/// property `=` is protected for in auto-indent.md §7.
3755///
3756/// A range that reflows to itself produces **no edit at all** rather
3757/// than an identical one — pressing `gqap` on an already-filled
3758/// paragraph must not push an undo step the user then has to press `u`
3759/// to get past.
3760///
3761/// One undo unit for the whole range, like `=` and its `>` / `<`
3762/// siblings.
3763fn operator_reflow(ctx: &mut OperatorContext) -> Result<Effect, CommandError> {
3764    if ctx.range.is_empty() {
3765        return Ok(Effect::None);
3766    }
3767    let first_line = ctx.range.start.line;
3768
3769    let buffer_text = ctx.document.text();
3770    let all: Vec<&str> = buffer_text.split('\n').collect();
3771    let last_line = ctx.range.end.line.min(all.len().saturating_sub(1) as u32);
3772    if first_line > last_line {
3773        return Ok(Effect::None);
3774    }
3775    ctx.cancel.check()?;
3776
3777    // RF.5b: the buffer's `format.reflow` chain says someone else owns
3778    // this. Hand the resolved range back rather than editing — an LSP
3779    // round-trip and a process spawn are both asynchronous, and the
3780    // grammar layer has neither client nor runtime. Same shape `g/`
3781    // uses to hand back a query instead of running a search.
3782    if !ctx.native_format.reflow {
3783        return Ok(Effect::AppAction(
3784            crate::app_effect::AppEffect::FormatRange {
3785                intent: lattice_core::FormatIntent::Reflow,
3786                start_line: first_line,
3787                end_line: last_line,
3788            },
3789        ));
3790    }
3791
3792    let slice: Vec<&str> = all[first_line as usize..=last_line as usize].to_vec();
3793    let cfg = crate::reflow::ReflowConfig {
3794        textwidth: ctx.textwidth.columns(),
3795        line_comment: ctx.comment_syntax.and_then(|c| c.line.as_deref()),
3796    };
3797    let out = crate::reflow::reflow_range(&slice, cfg);
3798    ctx.cancel.check()?;
3799
3800    // Identical output ⇒ no edit. Compared line by line rather than as
3801    // one joined string so a trailing-newline difference at the range
3802    // edge cannot masquerade as a change.
3803    if out.len() == slice.len() && out.iter().zip(slice.iter()).all(|(a, b)| a == b) {
3804        return Ok(Effect::None);
3805    }
3806
3807    // One replace over the whole range. Output lines do not correspond
3808    // one-to-one with input lines (that is what filling means), so a
3809    // per-line edit set would have to encode the joins and splits
3810    // itself. One edit is also one undo unit by construction.
3811    let end_col = all
3812        .get(last_line as usize)
3813        .map(|l| l.len() as u32)
3814        .unwrap_or(0);
3815    let range = lattice_protocol::position::Range::new(
3816        Position::new(first_line, 0),
3817        Position::new(last_line, end_col),
3818    );
3819    let applied = ctx
3820        .document
3821        .apply_edit_batch(vec![Edit::replace(range, out.join("\n"))])?;
3822    Ok(Effect::Edits(applied))
3823}
3824
3825/// `g=` — run the buffer's `format.reformat` chain over the range.
3826///
3827/// **Not a vim chord**, and it does not pretend to be. It exists because
3828/// `:format` had no operator form, so vim users reach for `gq` — the
3829/// only operator-shaped formatting verb available — and are then
3830/// disappointed when a reformatter declines to re-wrap their prose
3831/// (`text-reflow.md` §5). Giving the reformatter its own verb is the fix;
3832/// overloading `gq` is not.
3833///
3834/// Always delegates: every rung of a reformat chain is asynchronous (an
3835/// LSP round-trip, a process spawn), so there is nothing for the grammar
3836/// layer to do but hand back the range. That is why this has no native
3837/// branch where `=` and `gq` do.
3838/// VM.3h: `zf{motion}` / `{Visual}zf` — a closed fold over the whole lines the
3839/// range covers. Vim folds whole lines even for a charwise `v` selection.
3840///
3841/// A Visual selection or a linewise range already names whole lines, so its
3842/// lines are taken as they are. A `V` selection ends at its last line's
3843/// length, which is byte 0 when that line is empty, and the half-open rule
3844/// would wrongly drop it. A motion's span goes through
3845/// [`crate::range::span_to_whole_lines`], whose byte-0 rule is vim's
3846/// exclusive-linewise rule (`zf}` stops before the blank line).
3847fn operator_create_fold(ctx: &mut OperatorContext) -> Result<Effect, CommandError> {
3848    let (s, e) = (ctx.range.start, ctx.range.end);
3849    let whole_lines = ctx.linewise || ctx.document.selections().primary().visual.is_some();
3850    let (start_line, end_line) = if whole_lines {
3851        (s.line.min(e.line), s.line.max(e.line))
3852    } else {
3853        crate::range::span_to_whole_lines(s.line, s.byte, e.line, e.byte)
3854    };
3855    Ok(Effect::AppAction(
3856        crate::app_effect::AppEffect::CreateFold {
3857            start_line,
3858            end_line,
3859        },
3860    ))
3861}
3862
3863fn operator_reformat(ctx: &mut OperatorContext) -> Result<Effect, CommandError> {
3864    if ctx.range.is_empty() {
3865        return Ok(Effect::None);
3866    }
3867    Ok(Effect::AppAction(
3868        crate::app_effect::AppEffect::FormatRange {
3869            intent: lattice_core::FormatIntent::Reformat,
3870            start_line: ctx.range.start.line,
3871            end_line: ctx.range.end.line,
3872        },
3873    ))
3874}
3875
3876// ---- Case operators (gU, gu, g~) ----
3877
3878fn case_transform_in_range<F: Fn(u8) -> u8>(
3879    ctx: &mut OperatorContext,
3880    map: F,
3881) -> Result<Effect, CommandError> {
3882    if ctx.range.is_empty() {
3883        return Ok(Effect::None);
3884    }
3885    let original = ctx.document.buffer().slice(ctx.range)?;
3886    let transformed: String = original
3887        .as_bytes()
3888        .iter()
3889        .map(|&b| map(b) as char)
3890        .collect();
3891    if transformed == original {
3892        return Ok(Effect::None);
3893    }
3894    let edit = Edit::replace(ctx.range, &transformed);
3895    let applied = ctx.document.apply_edit(edit)?;
3896    Ok(Effect::Edits(vec![applied]))
3897}
3898
3899fn operator_upper(ctx: &mut OperatorContext) -> Result<Effect, CommandError> {
3900    case_transform_in_range(ctx, |b| b.to_ascii_uppercase())
3901}
3902
3903fn operator_lower(ctx: &mut OperatorContext) -> Result<Effect, CommandError> {
3904    case_transform_in_range(ctx, |b| b.to_ascii_lowercase())
3905}
3906
3907fn operator_toggle_case(ctx: &mut OperatorContext) -> Result<Effect, CommandError> {
3908    case_transform_in_range(ctx, |b| match b {
3909        b'a'..=b'z' => b - 32,
3910        b'A'..=b'Z' => b + 32,
3911        other => other,
3912    })
3913}
3914
3915/// `g/` (a Lattice extension, not vim-canonical): the project-search
3916/// operator. Read-only -- mirrors yank's no-edit shape. It slices the text
3917/// the motion / text object spans and emits the SAME `SearchTrigger` effect
3918/// the `:search` ex-command uses, so `g/{motion}` and `:search {text}` are
3919/// identical by construction. The query is used verbatim (literal,
3920/// case-insensitive -- project search's defaults); no regex escaping. An
3921/// empty or whitespace-only range is a no-op -- no empty search is launched.
3922fn operator_search(ctx: &mut OperatorContext) -> Result<Effect, CommandError> {
3923    if ctx.range.is_empty() {
3924        return Ok(Effect::None);
3925    }
3926    let query = ctx.document.buffer().slice(ctx.range)?;
3927    if query.trim().is_empty() {
3928        return Ok(Effect::None);
3929    }
3930    Ok(Effect::AppAction(
3931        crate::app_effect::AppEffect::SearchTrigger { query },
3932    ))
3933}
3934
3935#[cfg(test)]
3936mod tests {
3937    #![allow(clippy::unwrap_used, clippy::panic)]
3938    use super::*;
3939    use crate::CancellationToken;
3940    use crate::command::{CommandInvocation, Count};
3941    use crate::dispatcher::execute;
3942    use crate::effect::Effect;
3943    use crate::target::Target;
3944    use lattice_core::Document;
3945
3946    fn fixture(text: &str) -> (CommandRegistry, Builtins, Document) {
3947        let mut r = CommandRegistry::new();
3948        let b = populate(&mut r);
3949        let d = Document::from_text(text);
3950        (r, b, d)
3951    }
3952
3953    // Pull the query out of the `g/` operator's effect (a project-search
3954    // trigger), panicking on any other shape.
3955    fn search_query(eff: Effect) -> String {
3956        match eff {
3957            Effect::AppAction(crate::app_effect::AppEffect::SearchTrigger { query }) => query,
3958            other => panic!("expected SearchTrigger, got {other:?}"),
3959        }
3960    }
3961
3962    // ---- RF.2: the reflow operator (`gq` / `gw`) ----
3963
3964    /// Run `operator:reflow` over a whole-buffer range and return the
3965    /// resulting text. `Range::Whole` is the operator-composition path,
3966    /// so this exercises the real dispatch rather than calling `apply`.
3967    fn reflow_whole(text: &str, textwidth: usize, leader: Option<&str>) -> (String, Effect) {
3968        let (registry, b, mut doc) = fixture(text);
3969        let cancel = CancellationToken::never();
3970        let cs = leader.map(|l| crate::CommentSyntax {
3971            line: Some(l.to_string()),
3972            block: None,
3973        });
3974        let env = crate::registry::GrammarEnv {
3975            textwidth: lattice_core::WrapWidth(textwidth),
3976            comment_syntax: cs.as_ref(),
3977            ..Default::default()
3978        };
3979        let inv = CommandInvocation::of(b.reflow.0).with_range(crate::Range::Whole);
3980        let eff = crate::dispatcher::execute_with_env(
3981            &registry,
3982            &mut doc,
3983            lattice_core::BufferId(0),
3984            Position::new(0, 0),
3985            inv,
3986            &cancel,
3987            env,
3988        )
3989        .unwrap();
3990        (doc.text().to_string(), eff)
3991    }
3992
3993    #[test]
3994    fn reflow_fills_a_paragraph_to_textwidth() {
3995        let (out, _) = reflow_whole("aaa bbb ccc ddd eee fff\n", 11, None);
3996        assert_eq!(out, "aaa bbb ccc\nddd eee fff\n");
3997    }
3998
3999    #[test]
4000    fn reflow_keeps_a_comment_leader() {
4001        let (out, _) = reflow_whole("/// aaa bbb ccc ddd\n", 12, Some("//"));
4002        assert_eq!(out, "/// aaa bbb\n/// ccc ddd\n");
4003    }
4004
4005    /// A `gq` on an already-filled paragraph must produce NO edit — not
4006    /// an identical one. An edit that changes nothing still pushes an
4007    /// undo step, so the user presses `u` and watches nothing happen,
4008    /// twice.
4009    #[test]
4010    fn reflow_that_changes_nothing_produces_no_edit_at_all() {
4011        let (out, eff) = reflow_whole("aaa bbb\n", 80, None);
4012        assert_eq!(out, "aaa bbb\n");
4013        assert!(
4014            matches!(eff, Effect::None),
4015            "an already-filled paragraph must not push an undo step, got {eff:?}"
4016        );
4017    }
4018
4019    #[test]
4020    fn reflow_of_an_empty_range_is_a_noop() {
4021        let (registry, b, mut doc) = fixture("");
4022        let cancel = CancellationToken::never();
4023        let inv = CommandInvocation::of(b.reflow.0)
4024            .with_target(Target::TextObject(b.inner_word, crate::args::Args::None));
4025        let eff = execute(
4026            &registry,
4027            &mut doc,
4028            lattice_core::BufferId(0),
4029            Position::new(0, 0),
4030            inv,
4031            &cancel,
4032        )
4033        .unwrap();
4034        assert!(matches!(eff, Effect::None), "got {eff:?}");
4035    }
4036
4037    /// The whole range is ONE edit, which is what makes it one undo
4038    /// unit. Asserted on the effect rather than by pressing `u`, because
4039    /// the grammar layer has no undo — the host's test does that half
4040    /// (RF.2's TUI peer).
4041    #[test]
4042    fn reflow_emits_one_edit_for_the_whole_range() {
4043        let (_, eff) = reflow_whole("aaa bbb ccc ddd\n", 7, None);
4044        match eff {
4045            Effect::Edits(edits) => assert_eq!(
4046                edits.len(),
4047                1,
4048                "filling joins and splits lines, so a per-line edit set would \
4049                 have to encode that itself; one edit is also one undo unit \
4050                 by construction"
4051            ),
4052            other => panic!("expected Edits, got {other:?}"),
4053        }
4054    }
4055
4056    /// Reflow moves line breaks. It must NOT reindent — that is `=`'s
4057    /// job, and the separation is what keeps each verb composable with
4058    /// motions (auto-indent.md §7).
4059    #[test]
4060    fn reflow_does_not_reindent() {
4061        let (out, _) = reflow_whole("        aaa bbb ccc ddd\n", 20, None);
4062        assert_eq!(
4063            out, "        aaa bbb ccc\n        ddd\n",
4064            "the paragraph's indent is preserved verbatim, not normalised"
4065        );
4066    }
4067
4068    /// `gq` and `gw` are the same operator, so the doubled and mixed
4069    /// forms cannot disagree: there is only one id behind all four.
4070    #[test]
4071    fn gq_and_gw_are_one_operator() {
4072        let (_, b, _) = fixture("x\n");
4073        // A single `OperatorId` exists; the two chords are bound to one
4074        // prefix action in the host (keymap_normal.rs). This pins the
4075        // grammar half: nothing here distinguishes a `gq` reflow from a
4076        // `gw` one, so `gqq`, `gww`, `gqw` and `gwq` cannot drift.
4077        assert_ne!(b.reflow.0, b.reindent.0, "reflow is not reindent");
4078    }
4079
4080    // ---- RF.5b / RF.6: delegation to a non-native provider ----
4081
4082    /// The operator id MUST come from the same registry the dispatch
4083    /// runs against — command ids are process-global counters, so an id
4084    /// minted by one `fixture()` is simply absent from another's
4085    /// registry and resolves as `UnknownCommand`.
4086    fn run_whole(
4087        text: &str,
4088        pick: impl Fn(&Builtins) -> crate::registry::OperatorId,
4089        native: crate::registry::NativeFormatIntents,
4090    ) -> Effect {
4091        let (registry, b, mut doc) = fixture(text);
4092        let op = pick(&b);
4093        let cancel = CancellationToken::never();
4094        let env = crate::registry::GrammarEnv {
4095            textwidth: lattice_core::WrapWidth(11),
4096            native_format: native,
4097            ..Default::default()
4098        };
4099        let inv = CommandInvocation::of(op.0).with_range(crate::Range::Whole);
4100        crate::dispatcher::execute_with_env(
4101            &registry,
4102            &mut doc,
4103            lattice_core::BufferId(0),
4104            Position::new(0, 0),
4105            inv,
4106            &cancel,
4107            env,
4108        )
4109        .unwrap()
4110    }
4111
4112    /// The default env is native for everything, so the ~40 hand-built
4113    /// call sites — and the shipped configuration — never delegate.
4114    #[test]
4115    fn the_default_env_formats_natively() {
4116        assert_eq!(
4117            crate::registry::NativeFormatIntents::default(),
4118            crate::registry::NativeFormatIntents {
4119                indent: true,
4120                reflow: true
4121            }
4122        );
4123    }
4124
4125    /// `format.reflow` naming a non-native provider makes `gq` hand back
4126    /// its RANGE instead of editing — the operator cannot run an LSP
4127    /// round-trip or a process spawn itself.
4128    #[test]
4129    fn a_non_native_reflow_chain_hands_the_range_to_the_host() {
4130        let eff = run_whole(
4131            "aaa bbb ccc ddd\n",
4132            |b| b.reflow,
4133            crate::registry::NativeFormatIntents {
4134                indent: true,
4135                reflow: false,
4136            },
4137        );
4138        match eff {
4139            Effect::AppAction(crate::app_effect::AppEffect::FormatRange {
4140                intent,
4141                start_line,
4142                end_line,
4143            }) => {
4144                assert_eq!(intent, lattice_core::FormatIntent::Reflow);
4145                // One content line: a trailing newline is not an
4146                // addressable line, so `Range::Whole` is 0..=0 here.
4147                assert_eq!((start_line, end_line), (0, 0));
4148            }
4149            other => panic!("expected FormatRange, got {other:?}"),
4150        }
4151    }
4152
4153    /// …and a native chain still edits, so delegation is opt-in rather
4154    /// than the new default. Asserting only the delegating case would
4155    /// pass with the operator ALWAYS delegating.
4156    #[test]
4157    fn a_native_reflow_chain_still_edits_in_place() {
4158        let eff = run_whole(
4159            "aaa bbb ccc ddd\n",
4160            |b| b.reflow,
4161            crate::registry::NativeFormatIntents::default(),
4162        );
4163        assert!(
4164            matches!(eff, Effect::Edits(_)),
4165            "a native chain must edit, got {eff:?}"
4166        );
4167    }
4168
4169    /// `=` delegates on the SAME check, and does it before consulting
4170    /// the indent resolver — an external indenter is most of the reason
4171    /// to configure one, and it has to work on a buffer with no tree.
4172    #[test]
4173    fn a_non_native_indent_chain_delegates_without_a_tree() {
4174        let eff = run_whole(
4175            "    aaa\n",
4176            |b| b.reindent,
4177            crate::registry::NativeFormatIntents {
4178                indent: false,
4179                reflow: true,
4180            },
4181        );
4182        match eff {
4183            Effect::AppAction(crate::app_effect::AppEffect::FormatRange { intent, .. }) => {
4184                assert_eq!(intent, lattice_core::FormatIntent::Indent);
4185            }
4186            other => panic!("expected FormatRange, got {other:?}"),
4187        }
4188        // The env above carries NO `indent_resolver`, which is the
4189        // point: native `=` would no-op here, and delegation must not.
4190    }
4191
4192    /// `g=` always delegates — every rung of a reformat chain is
4193    /// asynchronous, so there is no native branch for it to take.
4194    #[test]
4195    fn g_equals_always_hands_the_range_to_the_host() {
4196        let eff = run_whole(
4197            "let x=1;\n",
4198            |b| b.reformat,
4199            crate::registry::NativeFormatIntents::default(),
4200        );
4201        match eff {
4202            Effect::AppAction(crate::app_effect::AppEffect::FormatRange { intent, .. }) => {
4203                assert_eq!(intent, lattice_core::FormatIntent::Reformat);
4204            }
4205            other => panic!("expected FormatRange even with a native env, got {other:?}"),
4206        }
4207    }
4208
4209    // ---- VM.3h: `zf` is an operator ----
4210
4211    /// Run `operator:create-fold` from `from` with a motion target and return
4212    /// the fold span it asks the host for.
4213    fn fold_span_for_motion(
4214        text: &str,
4215        from: Position,
4216        pick: impl Fn(&Builtins) -> crate::registry::MotionId,
4217    ) -> (u32, u32) {
4218        let (registry, b, mut doc) = fixture(text);
4219        let cancel = CancellationToken::never();
4220        let inv = CommandInvocation::of(b.create_fold.0).with_target(
4221            crate::target::Target::Motion(pick(&b), crate::args::Args::None),
4222        );
4223        let eff = crate::dispatcher::execute_with_env(
4224            &registry,
4225            &mut doc,
4226            lattice_core::BufferId(0),
4227            from,
4228            inv,
4229            &cancel,
4230            crate::registry::GrammarEnv::default(),
4231        )
4232        .expect("zf dispatches");
4233        match eff {
4234            Effect::AppAction(crate::app_effect::AppEffect::CreateFold {
4235                start_line,
4236                end_line,
4237            }) => (start_line, end_line),
4238            other => panic!("expected CreateFold, got {other:?}"),
4239        }
4240    }
4241
4242    /// `zfj` folds the cursor line and the next, from column 0 as well as
4243    /// mid-line. Vim ground truth: `zfj` folds two lines.
4244    #[test]
4245    fn zf_j_folds_two_whole_lines() {
4246        let text = "one\ntwo\nthree\n";
4247        assert_eq!(
4248            fold_span_for_motion(text, Position::new(0, 0), |b| b.line_down),
4249            (0, 1)
4250        );
4251        assert_eq!(
4252            fold_span_for_motion(text, Position::new(0, 2), |b| b.line_down),
4253            (0, 1)
4254        );
4255    }
4256
4257    /// `zf}` stops before the blank line: `}` is exclusive and ends at byte 0
4258    /// of it, which is vim's exclusive-linewise rule.
4259    #[test]
4260    fn zf_paragraph_forward_stops_before_the_blank_line() {
4261        let text = "a\nb\n\nc\n";
4262        assert_eq!(
4263            fold_span_for_motion(text, Position::new(0, 0), |b| b.paragraph_forward),
4264            (0, 1)
4265        );
4266    }
4267
4268    /// A linewise range names whole lines, so an empty last line isn't read
4269    /// as a half-open end.
4270    #[test]
4271    fn zf_over_a_linewise_range_keeps_an_empty_last_line() {
4272        let (registry, b, mut doc) = fixture("a\n\n");
4273        let cancel = CancellationToken::never();
4274        let inv = CommandInvocation::of(b.create_fold.0).with_range(crate::Range::Whole);
4275        let eff = crate::dispatcher::execute_with_env(
4276            &registry,
4277            &mut doc,
4278            lattice_core::BufferId(0),
4279            Position::new(0, 0),
4280            inv,
4281            &cancel,
4282            crate::registry::GrammarEnv::default(),
4283        )
4284        .expect("zf dispatches");
4285        match eff {
4286            Effect::AppAction(crate::app_effect::AppEffect::CreateFold {
4287                start_line,
4288                end_line,
4289            }) => assert_eq!((start_line, end_line), (0, 1)),
4290            other => panic!("expected CreateFold, got {other:?}"),
4291        }
4292    }
4293
4294    #[test]
4295    fn g_equals_over_an_empty_range_is_a_noop() {
4296        let (registry, b, mut doc) = fixture("");
4297        let cancel = CancellationToken::never();
4298        let inv = CommandInvocation::of(b.reformat.0)
4299            .with_target(Target::TextObject(b.inner_word, crate::args::Args::None));
4300        let eff = execute(
4301            &registry,
4302            &mut doc,
4303            lattice_core::BufferId(0),
4304            Position::new(0, 0),
4305            inv,
4306            &cancel,
4307        )
4308        .unwrap();
4309        assert!(matches!(eff, Effect::None), "got {eff:?}");
4310    }
4311
4312    #[test]
4313    fn search_operator_greps_word_under_cursor() {
4314        // g/iw -> project-search for the word under the cursor.
4315        let (registry, b, mut doc) = fixture("let needle = 1;\n");
4316        let cancel = CancellationToken::never();
4317        let cursor = Position::new(0, 4); // on `needle`
4318        let inv = CommandInvocation::of(b.search.0)
4319            .with_target(Target::TextObject(b.inner_word, crate::args::Args::None));
4320        let eff = execute(
4321            &registry,
4322            &mut doc,
4323            lattice_core::BufferId(0),
4324            cursor,
4325            inv,
4326            &cancel,
4327        )
4328        .unwrap();
4329        assert_eq!(search_query(eff), "needle");
4330    }
4331
4332    #[test]
4333    fn search_operator_greps_quoted_string() {
4334        // g/i" -> project-search for the quoted-string contents.
4335        let (registry, b, mut doc) = fixture("let s = \"find me\";\n");
4336        let cancel = CancellationToken::never();
4337        let cursor = Position::new(0, 10); // inside the quotes
4338        let inv = CommandInvocation::of(b.search.0).with_target(Target::TextObject(
4339            b.inner_quote_double,
4340            crate::args::Args::None,
4341        ));
4342        let eff = execute(
4343            &registry,
4344            &mut doc,
4345            lattice_core::BufferId(0),
4346            cursor,
4347            inv,
4348            &cancel,
4349        )
4350        .unwrap();
4351        assert_eq!(search_query(eff), "find me");
4352    }
4353
4354    #[test]
4355    fn search_operator_empty_range_is_noop() {
4356        // A motion that spans nothing produces no search.
4357        let (registry, b, mut doc) = fixture("\n");
4358        let cancel = CancellationToken::never();
4359        let cursor = Position::new(0, 0);
4360        let inv = CommandInvocation::of(b.search.0)
4361            .with_target(Target::TextObject(b.inner_word, crate::args::Args::None));
4362        let eff = execute(
4363            &registry,
4364            &mut doc,
4365            lattice_core::BufferId(0),
4366            cursor,
4367            inv,
4368            &cancel,
4369        )
4370        .unwrap();
4371        assert!(
4372            matches!(eff, Effect::None),
4373            "empty range must be a no-op, got {eff:?}"
4374        );
4375    }
4376
4377    #[test]
4378    fn comment_object_around_keeps_markers_inner_strips_leader() {
4379        use crate::dispatcher::execute_with_env;
4380        use crate::registry::{CommentSyntax, GrammarEnv};
4381        // line 1 `    // first`, line 2 `    // second` form one block.
4382        let src = "fn f() {\n    // first\n    // second\n    let x = 1;\n}\n";
4383        let (registry, b, mut doc) = fixture(src);
4384        let cs = CommentSyntax {
4385            line: Some("//".to_string()),
4386            block: None,
4387        };
4388        let cancel = CancellationToken::never();
4389        let cursor = Position::new(1, 8); // on `// first`
4390        // The yank operator yields `Effect::Many([Yank-unnamed,
4391        // Yank-numbered])`; pull the content out of whichever shape.
4392        fn yanked(eff: Effect) -> String {
4393            match eff {
4394                Effect::Yank { content, .. } => content,
4395                Effect::Many(effs) => effs
4396                    .into_iter()
4397                    .find_map(|e| match e {
4398                        Effect::Yank { content, .. } => Some(content),
4399                        _ => None,
4400                    })
4401                    .expect("a Yank inside Many"),
4402                other => panic!("expected a Yank, got {other:?}"),
4403            }
4404        }
4405
4406        // yaC -> the whole comment block (lines 1-2), markers included.
4407        let inv = CommandInvocation::of(b.yank.0).with_target(Target::TextObject(
4408            b.around_comment,
4409            crate::args::Args::None,
4410        ));
4411        let eff = execute_with_env(
4412            &registry,
4413            &mut doc,
4414            lattice_core::BufferId(0),
4415            cursor,
4416            inv,
4417            &cancel,
4418            GrammarEnv {
4419                scope_resolver: None,
4420                comment_syntax: Some(&cs),
4421                syntax: None,
4422                ..Default::default()
4423            },
4424        )
4425        .unwrap();
4426        let content = yanked(eff);
4427        assert!(
4428            content.contains("// first") && content.contains("// second"),
4429            "aC yanks the comment block including markers, got {content:?}"
4430        );
4431
4432        // yiC -> the comment text; the first line's leader is stripped.
4433        let inv = CommandInvocation::of(b.yank.0)
4434            .with_target(Target::TextObject(b.inner_comment, crate::args::Args::None));
4435        let eff = execute_with_env(
4436            &registry,
4437            &mut doc,
4438            lattice_core::BufferId(0),
4439            cursor,
4440            inv,
4441            &cancel,
4442            GrammarEnv {
4443                scope_resolver: None,
4444                comment_syntax: Some(&cs),
4445                syntax: None,
4446                ..Default::default()
4447            },
4448        )
4449        .unwrap();
4450        let content = yanked(eff);
4451        assert!(
4452            content.starts_with("first"),
4453            "iC strips the first line's leader (no `//`), got {content:?}"
4454        );
4455    }
4456
4457    #[test]
4458    fn comment_object_no_leader_is_a_noop() {
4459        // No comment_syntax in the env -> empty range -> the operator
4460        // no-ops (vim's `daC` with no comment does nothing).
4461        use crate::dispatcher::execute_with_env;
4462        use crate::registry::GrammarEnv;
4463        let src = "// a comment\n";
4464        let (registry, b, mut doc) = fixture(src);
4465        let cancel = CancellationToken::never();
4466        let inv = CommandInvocation::of(b.delete.0).with_target(Target::TextObject(
4467            b.around_comment,
4468            crate::args::Args::None,
4469        ));
4470        let eff = execute_with_env(
4471            &registry,
4472            &mut doc,
4473            lattice_core::BufferId(0),
4474            Position::new(0, 3),
4475            inv,
4476            &cancel,
4477            GrammarEnv::default(),
4478        )
4479        .unwrap();
4480        assert!(matches!(eff, Effect::None), "no leader -> no-op");
4481        assert_eq!(doc.text(), src, "document unchanged");
4482    }
4483
4484    #[test]
4485    fn populate_registers_known_builtins_by_name() {
4486        let mut r = CommandRegistry::new();
4487        let _ = populate(&mut r);
4488        assert!(r.lookup_by_name("motion:word-forward").is_some());
4489        assert!(r.lookup_by_name("operator:delete").is_some());
4490    }
4491
4492    #[test]
4493    fn pre_flipped_token_short_circuits_dispatch() {
4494        // DESIGN.md §5.2.5: an evaluator that observes a flipped
4495        // token returns Cancelled and commits no Effect.
4496        let (registry, b, mut doc) = fixture("hello world");
4497        let inv = CommandInvocation::of(b.word_forward.0);
4498        let cancel = CancellationToken::new();
4499        cancel.cancel();
4500        let result = execute(
4501            &registry,
4502            &mut doc,
4503            lattice_core::BufferId(0),
4504            Position::ZERO,
4505            inv,
4506            &cancel,
4507        );
4508        match result {
4509            Err(CommandError::Cancelled) => {}
4510            other => panic!("expected Cancelled, got {other:?}"),
4511        }
4512        // Document state unchanged: no edits land.
4513        assert_eq!(doc.text(), "hello world");
4514    }
4515
4516    #[test]
4517    fn fresh_token_does_not_short_circuit() {
4518        // Sanity-check the negative case: a fresh token leaves the
4519        // dispatcher's behaviour identical to the no-token path.
4520        let (registry, b, mut doc) = fixture("hello world");
4521        let inv = CommandInvocation::of(b.word_forward.0);
4522        let cancel = CancellationToken::new();
4523        let result = execute(
4524            &registry,
4525            &mut doc,
4526            lattice_core::BufferId(0),
4527            Position::ZERO,
4528            inv,
4529            &cancel,
4530        );
4531        assert!(result.is_ok());
4532    }
4533
4534    // ---- VM.3d-2: `n` / `N` are motions ----
4535
4536    const FOO: &str = "alpha foo one\nbeta two\ngamma foo three\ndelta four\n";
4537
4538    fn search_effect(
4539        from: Position,
4540        reverse: bool,
4541        count: Option<u32>,
4542        last: Option<&crate::registry::LastSearch>,
4543    ) -> Result<Effect, CommandError> {
4544        let (registry, b, mut doc) = fixture(FOO);
4545        let id = if reverse {
4546            b.search_prev
4547        } else {
4548            b.search_next
4549        };
4550        let mut inv = CommandInvocation::of(id.0);
4551        if let Some(n) = count {
4552            inv = inv.with_count(crate::command::Count(n));
4553        }
4554        let env = crate::registry::GrammarEnv {
4555            last_search: last,
4556            ..Default::default()
4557        };
4558        crate::dispatcher::execute_with_env(
4559            &registry,
4560            &mut doc,
4561            lattice_core::BufferId(0),
4562            from,
4563            inv,
4564            &CancellationToken::never(),
4565            env,
4566        )
4567    }
4568
4569    fn foo_forward() -> crate::registry::LastSearch {
4570        crate::registry::LastSearch {
4571            pattern: "foo".into(),
4572            direction: crate::modal::SearchDirection::Forward,
4573        }
4574    }
4575
4576    /// vim: `n` from 1,1 lands on the next `foo` (1,7), with no echo.
4577    #[test]
4578    fn n_moves_to_the_next_match_start() {
4579        let last = foo_forward();
4580        match search_effect(Position::new(0, 0), false, None, Some(&last)).unwrap() {
4581            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 6)),
4582            other => panic!("expected a plain CursorMove, got {other:?}"),
4583        }
4584    }
4585
4586    /// `2n` counts; `N` reverses the search's own direction.
4587    #[test]
4588    fn a_count_repeats_and_upper_n_reverses() {
4589        let last = foo_forward();
4590        match search_effect(Position::new(0, 0), false, Some(2), Some(&last)).unwrap() {
4591            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(2, 6)),
4592            other => panic!("expected CursorMove, got {other:?}"),
4593        }
4594        match search_effect(Position::new(2, 9), true, None, Some(&last)).unwrap() {
4595            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(2, 6)),
4596            other => panic!("expected CursorMove, got {other:?}"),
4597        }
4598    }
4599
4600    /// vim: `n` past the last match wraps to the first and says so.
4601    #[test]
4602    fn a_wrapping_n_echoes_search_hit_bottom() {
4603        let last = foo_forward();
4604        match search_effect(Position::new(2, 9), false, None, Some(&last)).unwrap() {
4605            Effect::Many(parts) => {
4606                assert!(matches!(parts[0], Effect::CursorMove(p) if p == Position::new(0, 6)));
4607                assert!(matches!(
4608                    &parts[1],
4609                    Effect::Echo { level: crate::effect::EchoLevel::Warn, text }
4610                        if text == "search hit BOTTOM, continuing at TOP"
4611                ));
4612            }
4613            other => panic!("expected CursorMove + Echo, got {other:?}"),
4614        }
4615    }
4616
4617    /// vim: no match is E486; no previous search is E35. Both are user-facing
4618    /// errors, so the host echoes them and an operator commits nothing.
4619    #[test]
4620    fn a_failed_search_is_a_user_facing_error() {
4621        let missing = crate::registry::LastSearch {
4622            pattern: "zzz".into(),
4623            direction: crate::modal::SearchDirection::Forward,
4624        };
4625        match search_effect(Position::new(0, 0), false, None, Some(&missing)) {
4626            Err(CommandError::User(msg)) => assert_eq!(msg, "E486: Pattern not found: zzz"),
4627            other => panic!("expected E486, got {other:?}"),
4628        }
4629        match search_effect(Position::new(0, 0), false, None, None) {
4630            Err(CommandError::User(msg)) => assert_eq!(msg, "E35: no previous regular expression"),
4631            other => panic!("expected E35, got {other:?}"),
4632        }
4633    }
4634
4635    // ---- VM.3g-2: `gj` / `gk` / `g0` / `g$` (vim 9.2, `vimcheck_gj.vim`) ----
4636
4637    /// The vim check's buffer: a short line, a 200-column line, a short line,
4638    /// wrapped at 80. Byte columns here are 0-based; the vim rows quoted in the
4639    /// test names are 1-based, so they differ by one.
4640    fn wrapped_fixture() -> (CommandRegistry, Builtins, Document, WrapAt80) {
4641        let long: String = std::iter::repeat_n("abcdefghij", 20).collect();
4642        let text = format!("  short one\n{long}\n  short three");
4643        let (r, b, d) = fixture(&text);
4644        (r, b, d, WrapAt80)
4645    }
4646
4647    struct WrapAt80;
4648
4649    impl crate::registry::DisplayResolver for WrapAt80 {
4650        fn wrap_width(&self) -> u32 {
4651            80
4652        }
4653        fn segments(&self, line: u32) -> u32 {
4654            // 11, 200, 13 columns → 1, 3, 1 rows at width 80.
4655            if line == 1 { 3 } else { 1 }
4656        }
4657    }
4658
4659    fn display_motion(
4660        pick: impl Fn(&Builtins) -> crate::registry::MotionId,
4661        from: Position,
4662        count: Option<u32>,
4663        wrapped: bool,
4664    ) -> Position {
4665        let (registry, b, mut doc, display) = wrapped_fixture();
4666        let mut inv = CommandInvocation::of(pick(&b).0);
4667        if let Some(n) = count {
4668            inv = inv.with_count(crate::command::Count(n));
4669        }
4670        let env = crate::registry::GrammarEnv {
4671            display: wrapped.then_some(&display as &dyn crate::registry::DisplayResolver),
4672            ..Default::default()
4673        };
4674        match crate::dispatcher::execute_with_env(
4675            &registry,
4676            &mut doc,
4677            lattice_core::BufferId(0),
4678            from,
4679            inv,
4680            &CancellationToken::never(),
4681            env,
4682        )
4683        .unwrap()
4684        {
4685            Effect::CursorMove(p) => p,
4686            other => panic!("expected CursorMove, got {other:?}"),
4687        }
4688    }
4689
4690    /// vim: `gj` from 2,5 → 2,85 — the same screen column, one row down; `gk`
4691    /// comes back; `2gj` walks two rows (the host action ignored counts).
4692    #[test]
4693    fn gj_and_gk_move_by_display_row() {
4694        assert_eq!(
4695            display_motion(|b| b.display_line_down, Position::new(1, 4), None, true),
4696            Position::new(1, 84)
4697        );
4698        assert_eq!(
4699            display_motion(|b| b.display_line_up, Position::new(1, 84), None, true),
4700            Position::new(1, 4)
4701        );
4702        assert_eq!(
4703            display_motion(|b| b.display_line_down, Position::new(1, 4), Some(2), true),
4704            Position::new(1, 164)
4705        );
4706    }
4707
4708    /// The goal column a motion REPORTS, which for `gj` / `gk` is not always
4709    /// the column it reached.
4710    fn display_motion_curswant(
4711        pick: impl Fn(&Builtins) -> crate::registry::MotionId,
4712        from: Position,
4713        curswant_in: Option<crate::registry::Curswant>,
4714    ) -> (Position, Option<crate::registry::Curswant>) {
4715        let (registry, b, mut doc, display) = wrapped_fixture();
4716        let slot = std::sync::Mutex::new(None);
4717        let env = crate::registry::GrammarEnv {
4718            display: Some(&display as &dyn crate::registry::DisplayResolver),
4719            curswant: curswant_in,
4720            curswant_out: Some(&slot),
4721            ..Default::default()
4722        };
4723        let landed = match crate::dispatcher::execute_with_env(
4724            &registry,
4725            &mut doc,
4726            lattice_core::BufferId(0),
4727            from,
4728            CommandInvocation::of(pick(&b).0),
4729            &CancellationToken::never(),
4730            env,
4731        )
4732        .unwrap()
4733        {
4734            Effect::CursorMove(p) => p,
4735            other => panic!("expected CursorMove, got {other:?}"),
4736        };
4737        let reported = *slot.lock().unwrap();
4738        (landed, reported)
4739    }
4740
4741    /// VM.3g-3: a CLAMPED `gj` records the column it aimed at, not the one it
4742    /// reached.
4743    ///
4744    /// vim 9.2 (`vimcheck_scroll_curswant.vim`), wrap 80 over a 240-char line
4745    /// then a 100-char line: `gj` onto the short second row lands at column 100
4746    /// and records `curswant` 160 — the aim. Same shape here on the 200-char
4747    /// fixture line: row 3 holds bytes 160..199, so `gj` from byte 159 aims at
4748    /// 160 + 79 = 239, lands clamped at 199, and must report 239.
4749    ///
4750    /// Recording 199 instead would lose the aim permanently: a following `j`
4751    /// onto a long line returns to 199 where vim returns to 239.
4752    #[test]
4753    fn a_clamped_gj_reports_the_aim_not_the_landing() {
4754        let (landed, reported) = display_motion_curswant(
4755            |b| b.display_line_down,
4756            Position::new(1, 159),
4757            Some(crate::registry::Curswant::Col(159)),
4758        );
4759        assert_eq!(landed, Position::new(1, 199), "the row ends at byte 199");
4760        assert_eq!(
4761            reported,
4762            Some(crate::registry::Curswant::Col(239)),
4763            "but the aim was 239, and that is what vim keeps"
4764        );
4765    }
4766
4767    /// The unclamped case must report too, and there the aim IS the landing —
4768    /// which is why the simpler "set from where you landed" rule passed every
4769    /// test VM.3g-2 had.
4770    #[test]
4771    fn an_unclamped_gj_reports_the_column_it_reached() {
4772        let (landed, reported) = display_motion_curswant(
4773            |b| b.display_line_down,
4774            Position::new(1, 4),
4775            Some(crate::registry::Curswant::Col(4)),
4776        );
4777        assert_eq!(landed, Position::new(1, 84));
4778        assert_eq!(reported, Some(crate::registry::Curswant::Col(84)));
4779    }
4780
4781    /// `gk` is the mirror, and reports on the same rule.
4782    #[test]
4783    fn gk_reports_its_aim_too() {
4784        let (landed, reported) = display_motion_curswant(
4785            |b| b.display_line_up,
4786            Position::new(1, 84),
4787            Some(crate::registry::Curswant::Col(84)),
4788        );
4789        assert_eq!(landed, Position::new(1, 4));
4790        assert_eq!(reported, Some(crate::registry::Curswant::Col(4)));
4791    }
4792
4793    /// Every other motion reports NOTHING, so the host falls through to the
4794    /// spec's `CurswantEffect` exactly as before. A motion that started
4795    /// reporting by accident would silently pin the goal column.
4796    #[test]
4797    fn an_ordinary_motion_reports_no_goal() {
4798        let (_, reported) = display_motion_curswant(|b| b.line_down, Position::new(1, 4), None);
4799        assert_eq!(reported, None);
4800    }
4801
4802    /// vim: `gj` on the last display row stays put rather than failing.
4803    #[test]
4804    fn gj_on_the_last_display_row_stays() {
4805        assert_eq!(
4806            display_motion(|b| b.display_line_down, Position::new(2, 4), None, true),
4807            Position::new(2, 4)
4808        );
4809    }
4810
4811    /// vim: `g0` → 2,81 (the row's first column) and `g$` → 2,160 (its last).
4812    #[test]
4813    fn g0_and_g_dollar_bound_the_display_row() {
4814        assert_eq!(
4815            display_motion(|b| b.display_line_start, Position::new(1, 84), None, true),
4816            Position::new(1, 80)
4817        );
4818        assert_eq!(
4819            display_motion(|b| b.display_line_end, Position::new(1, 84), None, true),
4820            Position::new(1, 159)
4821        );
4822    }
4823
4824    /// With `wrap` off — or no renderer geometry at all — they ARE `j` / `k` /
4825    /// `0`, as vim does.
4826    #[test]
4827    fn without_wrap_they_degrade_to_the_line_motions() {
4828        assert_eq!(
4829            display_motion(|b| b.display_line_down, Position::new(0, 4), None, false),
4830            Position::new(1, 4),
4831            "`gj` is `j`"
4832        );
4833        assert_eq!(
4834            display_motion(|b| b.display_line_start, Position::new(1, 84), None, false),
4835            Position::new(1, 0),
4836            "`g0` is `0`"
4837        );
4838    }
4839
4840    /// All four SET the goal from where they land — measured in vim 9.2, and
4841    /// the opposite of `j` / `k`, which keep theirs across a short line. `gj`
4842    /// recomputes the virtual column for the row it reached, so "keep" would be
4843    /// wrong: after `gj` from 2,5 vim records 85, not 5.
4844    #[test]
4845    fn the_display_motions_set_the_goal_from_where_they_land() {
4846        use crate::registry::CurswantEffect;
4847        let (registry, b, _d, _w) = wrapped_fixture();
4848        for id in [
4849            b.display_line_down,
4850            b.display_line_up,
4851            b.display_line_start,
4852            b.display_line_end,
4853        ] {
4854            assert_eq!(
4855                registry.motion_curswant(id.0),
4856                CurswantEffect::SetFromTarget
4857            );
4858        }
4859        // `j` is the contrast, and it is not a display motion.
4860        assert_eq!(
4861            registry.motion_curswant(b.line_down.0),
4862            CurswantEffect::Keep
4863        );
4864    }
4865
4866    // ---- VM.3g-1: the goal column (vim's `curswant`) ----
4867
4868    const RAGGED: &str = "abcdefghijkl\nab\nabcdefghijkl\n";
4869
4870    fn vertical(
4871        down: bool,
4872        from: Position,
4873        curswant: Option<crate::registry::Curswant>,
4874    ) -> Position {
4875        let (registry, b, mut doc) = fixture(RAGGED);
4876        let motion = if down { b.line_down } else { b.line_up };
4877        let env = crate::registry::GrammarEnv {
4878            curswant,
4879            ..Default::default()
4880        };
4881        match crate::dispatcher::execute_with_env(
4882            &registry,
4883            &mut doc,
4884            lattice_core::BufferId(0),
4885            from,
4886            CommandInvocation::of(motion.0),
4887            &CancellationToken::never(),
4888            env,
4889        )
4890        .unwrap()
4891        {
4892            Effect::CursorMove(p) => p,
4893            other => panic!("expected CursorMove, got {other:?}"),
4894        }
4895    }
4896
4897    /// vim 9.2: `j` onto a SHORT line clamps to it, but the goal column is
4898    /// remembered — so the next `j` returns to column 9, not to 2. The motion's
4899    /// half of that is aiming at the goal rather than at the cursor.
4900    #[test]
4901    fn a_vertical_motion_aims_at_the_goal_column_not_the_cursor() {
4902        use crate::registry::Curswant;
4903        // Onto the short line: clamped.
4904        assert_eq!(
4905            vertical(true, Position::new(0, 8), Some(Curswant::Col(8))),
4906            Position::new(1, 2)
4907        );
4908        // Off it again, FROM the clamped column: the goal wins.
4909        assert_eq!(
4910            vertical(true, Position::new(1, 2), Some(Curswant::Col(8))),
4911            Position::new(2, 8)
4912        );
4913        // And upward, the same.
4914        assert_eq!(
4915            vertical(false, Position::new(1, 2), Some(Curswant::Col(8))),
4916            Position::new(0, 8)
4917        );
4918    }
4919
4920    /// vim: after `$` the goal is MAXCOL, so every line it passes through is
4921    /// entered at its own end — `$jj` is at each line's end, not at a column.
4922    #[test]
4923    fn an_end_of_line_goal_lands_on_each_lines_end() {
4924        use crate::registry::Curswant;
4925        assert_eq!(
4926            vertical(true, Position::new(0, 12), Some(Curswant::EndOfLine)),
4927            Position::new(1, 2),
4928            "the short line's end"
4929        );
4930        assert_eq!(
4931            vertical(true, Position::new(1, 2), Some(Curswant::EndOfLine)),
4932            Position::new(2, 12),
4933            "and the long line's end, not the short one's column"
4934        );
4935    }
4936
4937    /// With no goal recorded the cursor's own column is the goal — what the
4938    /// first `j` after an edit does.
4939    #[test]
4940    fn no_goal_yet_means_the_cursors_own_column() {
4941        assert_eq!(
4942            vertical(true, Position::new(0, 5), None),
4943            Position::new(1, 2)
4944        );
4945        assert_eq!(
4946            vertical(true, Position::new(1, 1), None),
4947            Position::new(2, 1)
4948        );
4949    }
4950
4951    /// The three motions that do not simply set the goal from where they land.
4952    #[test]
4953    fn j_k_keep_the_goal_and_dollar_pins_it() {
4954        use crate::registry::CurswantEffect;
4955        let (registry, b, _doc) = fixture(RAGGED);
4956        assert_eq!(
4957            registry.motion_curswant(b.line_down.0),
4958            CurswantEffect::Keep
4959        );
4960        assert_eq!(registry.motion_curswant(b.line_up.0), CurswantEffect::Keep);
4961        assert_eq!(
4962            registry.motion_curswant(b.line_end.0),
4963            CurswantEffect::PinToEnd
4964        );
4965        // Everything else, and everything that is not a motion at all.
4966        assert_eq!(
4967            registry.motion_curswant(b.word_forward.0),
4968            CurswantEffect::SetFromTarget
4969        );
4970        assert_eq!(
4971            registry.motion_curswant(b.delete.0),
4972            CurswantEffect::SetFromTarget
4973        );
4974    }
4975
4976    // ---- VM.3j-1: `gg` / `G` honour `startofline` ----
4977
4978    /// `first_line` picks `gg`, else `G`. The motion id comes from THIS
4979    /// fixture's registry: command ids are globally unique, so an id taken from
4980    /// a second `fixture()` call is unknown to this one.
4981    fn sol_effect(first_line: bool, from: Position, nostartofline: bool) -> Effect {
4982        let (registry, b, mut doc) = fixture("  one a\n    two b\n  three c\n");
4983        let motion = if first_line {
4984            b.goto_first_line
4985        } else {
4986            b.goto_last_line
4987        };
4988        let env = crate::registry::GrammarEnv {
4989            nostartofline,
4990            ..Default::default()
4991        };
4992        crate::dispatcher::execute_with_env(
4993            &registry,
4994            &mut doc,
4995            lattice_core::BufferId(0),
4996            from,
4997            CommandInvocation::of(motion.0),
4998            &CancellationToken::never(),
4999            env,
5000        )
5001        .unwrap()
5002    }
5003
5004    /// vim 9.2 (`vimcheck_sol_pages.vim`): with `startofline` — the default —
5005    /// `gg` and `G` land on the first non-blank of their line, not column 0.
5006    #[test]
5007    fn gg_and_g_land_on_the_first_non_blank() {
5008        match sol_effect(true, Position::new(2, 5), false) {
5009            Effect::CursorMove(p) => assert_eq!(p, Position::new(0, 2)),
5010            other => panic!("expected CursorMove, got {other:?}"),
5011        }
5012        match sol_effect(false, Position::new(0, 5), false) {
5013            Effect::CursorMove(p) => assert_eq!(p, Position::new(2, 2)),
5014            other => panic!("expected CursorMove, got {other:?}"),
5015        }
5016    }
5017
5018    /// vim: with `nostartofline` both keep the cursor's column, clamped.
5019    #[test]
5020    fn nostartofline_keeps_the_column_on_gg_and_g() {
5021        match sol_effect(true, Position::new(2, 5), true) {
5022            Effect::CursorMove(p) => assert_eq!(p, Position::new(0, 5)),
5023            other => panic!("expected CursorMove, got {other:?}"),
5024        }
5025        match sol_effect(false, Position::new(1, 8), true) {
5026            Effect::CursorMove(p) => assert_eq!(p, Position::new(2, 8)),
5027            other => panic!("expected CursorMove, got {other:?}"),
5028        }
5029    }
5030
5031    // ---- VM.3m: where an operator leaves the cursor ----
5032
5033    /// vim: `dd` from 1,6 lands on the first non-blank of the line that moved
5034    /// up (1,5 when that line is indented four).
5035    #[test]
5036    fn a_linewise_delete_lands_on_the_first_non_blank() {
5037        let (registry, b, mut doc) = fixture("  one a\n    two b\n  three c\n");
5038        let inv = CommandInvocation::of(b.delete.0).with_range(crate::range::Range::CurrentLine);
5039        let effect = execute(
5040            &registry,
5041            &mut doc,
5042            lattice_core::BufferId(0),
5043            Position::new(0, 5),
5044            inv,
5045            &CancellationToken::never(),
5046        )
5047        .unwrap();
5048        match effect {
5049            Effect::Many(parts) => assert!(matches!(
5050                parts[parts.len() - 1],
5051                Effect::CursorMove(p) if p == Position::new(0, 4)
5052            )),
5053            other => panic!("expected Many, got {other:?}"),
5054        }
5055    }
5056
5057    /// vim: `yk` from 2,8 lands on 1,7 — the start line, column kept. The
5058    /// expanded range starts at column 0, so this is `origin`, not `range`.
5059    #[test]
5060    fn a_yank_lands_on_the_start_of_what_it_yanked() {
5061        let (registry, b, mut doc) = fixture("  one a\n    two b\n  three c\n");
5062        let inv = CommandInvocation::of(b.yank.0)
5063            .with_target(Target::Motion(b.line_up, crate::args::Args::None));
5064        let effect = execute(
5065            &registry,
5066            &mut doc,
5067            lattice_core::BufferId(0),
5068            Position::new(1, 6),
5069            inv,
5070            &CancellationToken::never(),
5071        )
5072        .unwrap();
5073        match effect {
5074            Effect::Many(parts) => assert!(matches!(
5075                parts[parts.len() - 1],
5076                Effect::CursorMove(p) if p == Position::new(0, 6)
5077            )),
5078            other => panic!("expected Many, got {other:?}"),
5079        }
5080    }
5081
5082    // ---- VM.3f: `H` / `M` / `L` are motions ----
5083
5084    fn shown(first: u32, count: u32) -> crate::registry::ShownLines {
5085        crate::registry::ShownLines((first..first + count).map(|l| (l, 1.0)).collect())
5086    }
5087
5088    /// vim 9.2, a 21-row window showing lines 6–26 (1-based): `H` 6, `M` 16,
5089    /// `L` 26, `3H` 8, `3L` 24, and a count past the window clamps to the far
5090    /// edge.
5091    #[test]
5092    fn shown_lines_answer_h_m_and_l_as_vim_does() {
5093        use crate::app_effect::ViewportPos::{Bottom, Middle, Top};
5094        use crate::registry::ViewportResolver;
5095        let window = shown(5, 21);
5096        assert_eq!(window.viewport_line(Top, 1), Some(5));
5097        assert_eq!(window.viewport_line(Middle, 1), Some(15));
5098        assert_eq!(window.viewport_line(Bottom, 1), Some(25));
5099        assert_eq!(window.viewport_line(Top, 3), Some(7));
5100        assert_eq!(window.viewport_line(Bottom, 3), Some(23));
5101        assert_eq!(window.viewport_line(Top, 99), Some(25));
5102        assert_eq!(window.viewport_line(Bottom, 99), Some(5));
5103    }
5104
5105    /// vim: `M` is top + (shown − 1) / 2 — 22 rows → +10, 23 → +11, a 6-line
5106    /// buffer → its 3rd line — and a tall row spends its height.
5107    #[test]
5108    fn middle_is_half_the_lines_shown_less_one() {
5109        use crate::app_effect::ViewportPos::Middle;
5110        use crate::registry::{ShownLines, ViewportResolver};
5111        assert_eq!(shown(0, 22).viewport_line(Middle, 1), Some(10));
5112        assert_eq!(shown(0, 23).viewport_line(Middle, 1), Some(11));
5113        assert_eq!(shown(0, 6).viewport_line(Middle, 1), Some(2));
5114        let mut rows: Vec<(u32, f32)> = (0..9).map(|l| (l, 1.0)).collect();
5115        rows[2].1 = 4.0;
5116        assert_eq!(ShownLines(rows).viewport_line(Middle, 1), Some(2));
5117        assert_eq!(ShownLines::default().viewport_line(Middle, 1), None);
5118    }
5119
5120    /// VM.3f: `H` / `L` stop `scrolloff` lines inside the window's edges.
5121    ///
5122    /// The numbers are vim 9.2's, measured with an 11-line window over a
5123    /// 100-line file and the cursor centred (`50Gzz` → shows 45–55, 0-based
5124    /// 44–54): `H` → 48 and `L` → 52 with `scrolloff=3`, 45 and 55 with `0`.
5125    /// At a FILE edge vim does not adjust — `gg`-then-`H` is line 1 and
5126    /// `G`-then-`L` the last line — because the margin exists to keep context
5127    /// on screen and there is none past the end.
5128    #[test]
5129    fn h_and_l_keep_scrolloff_from_the_windows_edges() {
5130        use crate::app_effect::ViewportPos::{Bottom, Top};
5131        let long = "x\n".repeat(100);
5132        let (_registry, _b, doc) = fixture(&long);
5133        let window = shown(44, 11);
5134        let motion = |pos, scrolloff, operator_pending| {
5135            let ctx = MotionContext {
5136                buffer: doc.buffer(),
5137                buffer_id: lattice_core::BufferId(0),
5138                from: Position::new(49, 0),
5139                count: Count::default(),
5140                has_explicit_count: false,
5141                args: crate::args::Args::None,
5142                cancel: &CancellationToken::never(),
5143                scope_resolver: None,
5144                path: None,
5145                syntax: None,
5146                last_find: None,
5147                fold_resolver: None,
5148                last_search: None,
5149                marks: None,
5150                viewport: Some(&window as &dyn crate::registry::ViewportResolver),
5151                nostartofline: false,
5152                curswant: None,
5153                display: None,
5154                scrolloff,
5155                operator_pending,
5156            };
5157            motion_viewport(&ctx, pos).unwrap().target.line
5158        };
5159
5160        assert_eq!(motion(Top, 3, false), 47, "`H` stops 3 inside the top");
5161        assert_eq!(
5162            motion(Bottom, 3, false),
5163            51,
5164            "`L` stops 3 inside the bottom"
5165        );
5166        assert_eq!(motion(Top, 0, false), 44, "`scrolloff=0` reaches the edge");
5167        assert_eq!(motion(Bottom, 0, false), 54);
5168        // `:h H`: not adjusted under a pending operator, so `dH` deletes to
5169        // the window's real top edge.
5170        assert_eq!(motion(Top, 3, true), 44, "an operator target is unadjusted");
5171        assert_eq!(motion(Bottom, 3, true), 54);
5172
5173        // A window at the file's edges: nothing above line 0 to keep in view,
5174        // and nothing below the last line.
5175        let top_of_file = shown(0, 11);
5176        let ctx_at = |window: &crate::registry::ShownLines, pos| {
5177            let ctx = MotionContext {
5178                buffer: doc.buffer(),
5179                buffer_id: lattice_core::BufferId(0),
5180                from: Position::new(5, 0),
5181                count: Count::default(),
5182                has_explicit_count: false,
5183                args: crate::args::Args::None,
5184                cancel: &CancellationToken::never(),
5185                scope_resolver: None,
5186                path: None,
5187                syntax: None,
5188                last_find: None,
5189                fold_resolver: None,
5190                last_search: None,
5191                marks: None,
5192                viewport: Some(window as &dyn crate::registry::ViewportResolver),
5193                nostartofline: false,
5194                curswant: None,
5195                display: None,
5196                scrolloff: 3,
5197                operator_pending: false,
5198            };
5199            motion_viewport(&ctx, pos).unwrap().target.line
5200        };
5201        assert_eq!(ctx_at(&top_of_file, Top), 0, "`H` on the file's first line");
5202        // The window's bottom IS the last line (the fixture's trailing newline
5203        // makes 99 the last addressable one).
5204        let end_of_file = shown(89, 11);
5205        assert_eq!(
5206            ctx_at(&end_of_file, Bottom),
5207            99,
5208            "`L` on the file's last line"
5209        );
5210    }
5211
5212    fn viewport_effect(nostartofline: bool, with_window: bool) -> Result<Effect, CommandError> {
5213        let (registry, b, mut doc) = fixture("  one\n  two\n  three\n");
5214        let window = shown(1, 2);
5215        let env = crate::registry::GrammarEnv {
5216            viewport: with_window.then_some(&window as &dyn crate::registry::ViewportResolver),
5217            nostartofline,
5218            ..Default::default()
5219        };
5220        crate::dispatcher::execute_with_env(
5221            &registry,
5222            &mut doc,
5223            lattice_core::BufferId(0),
5224            Position::new(2, 4),
5225            CommandInvocation::of(b.viewport_top.0),
5226            &CancellationToken::never(),
5227            env,
5228        )
5229    }
5230
5231    /// `H` lands on the first non-blank, keeps the column with `nostartofline`,
5232    /// and with no window fails rather than guessing.
5233    #[test]
5234    fn a_viewport_motion_follows_startofline() {
5235        match viewport_effect(false, true).unwrap() {
5236            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(1, 2)),
5237            other => panic!("expected CursorMove, got {other:?}"),
5238        }
5239        match viewport_effect(true, true).unwrap() {
5240            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(1, 4)),
5241            other => panic!("expected CursorMove, got {other:?}"),
5242        }
5243        assert!(matches!(
5244            viewport_effect(false, false),
5245            Err(CommandError::MotionFailed)
5246        ));
5247    }
5248
5249    // ---- VM.3e: `'x` / `` `x `` are motions ----
5250
5251    const MARKS: &str = "  one a\n  two b\n\n  four d\n  five e\n  six f\n";
5252
5253    fn mark_effect(
5254        exact: bool,
5255        name: char,
5256        marks: Option<&std::collections::HashMap<char, Position>>,
5257    ) -> Result<Effect, CommandError> {
5258        let (registry, b, mut doc) = fixture(MARKS);
5259        let id = if exact { b.mark_exact } else { b.mark_line };
5260        let inv = CommandInvocation::of(id.0).with_args(crate::args::Args::Char(name));
5261        let env = crate::registry::GrammarEnv {
5262            marks: marks.map(|m| m as &dyn crate::registry::MarkResolver),
5263            ..Default::default()
5264        };
5265        crate::dispatcher::execute_with_env(
5266            &registry,
5267            &mut doc,
5268            lattice_core::BufferId(0),
5269            Position::new(0, 4),
5270            inv,
5271            &CancellationToken::never(),
5272            env,
5273        )
5274    }
5275
5276    fn mark_a_at(line: u32, byte: u32) -> std::collections::HashMap<char, Position> {
5277        std::collections::HashMap::from([('a', Position::new(line, byte))])
5278    }
5279
5280    /// vim: `'a` lands on the first non-blank of the mark's line; `` `a `` on
5281    /// the mark itself.
5282    #[test]
5283    fn a_mark_motion_targets_its_line_or_its_exact_position() {
5284        let marks = mark_a_at(4, 5);
5285        match mark_effect(false, 'a', Some(&marks)).unwrap() {
5286            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(4, 2)),
5287            other => panic!("expected CursorMove, got {other:?}"),
5288        }
5289        match mark_effect(true, 'a', Some(&marks)).unwrap() {
5290            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(4, 5)),
5291            other => panic!("expected CursorMove, got {other:?}"),
5292        }
5293    }
5294
5295    /// vim: an unset mark is E20 — no table, or a table without that mark.
5296    #[test]
5297    fn an_unset_mark_is_e20() {
5298        let marks = mark_a_at(4, 5);
5299        for result in [
5300            mark_effect(false, 'b', Some(&marks)),
5301            mark_effect(true, 'a', None),
5302        ] {
5303            match result {
5304                Err(CommandError::User(msg)) => assert_eq!(msg, "E20: Mark not set"),
5305                other => panic!("expected E20, got {other:?}"),
5306            }
5307        }
5308    }
5309
5310    /// A mark past the end of a buffer that has since shrunk resolves to the
5311    /// last position there is, rather than failing or landing out of range.
5312    #[test]
5313    fn a_mark_past_the_end_is_clamped_into_the_buffer() {
5314        let marks = mark_a_at(40, 9);
5315        match mark_effect(true, 'a', Some(&marks)).unwrap() {
5316            Effect::CursorMove(pos) => assert_eq!(pos.line, 5),
5317            other => panic!("expected CursorMove, got {other:?}"),
5318        }
5319    }
5320
5321    // ---- VM.3i: `zj` / `zk` are motions ----
5322
5323    /// Every fold open, so edges are plain starts / ends: which folds are
5324    /// VISIBLE is the host's rule and is tested there.
5325    struct OpenFolds(Vec<(u32, u32)>);
5326
5327    impl crate::registry::FoldResolver for OpenFolds {
5328        fn fold_edge(&self, line: u32, forward: bool) -> Option<u32> {
5329            if forward {
5330                self.0.iter().map(|f| f.0).filter(|&s| s > line).min()
5331            } else {
5332                self.0.iter().map(|f| f.1).filter(|&e| e < line).max()
5333            }
5334        }
5335    }
5336
5337    /// The layout of the vim 9.2 check: folds on lines 4–6 and 9–10 (1-based),
5338    /// in a 14-line buffer.
5339    fn vim_folds() -> OpenFolds {
5340        OpenFolds(vec![(3, 5), (8, 9)])
5341    }
5342
5343    fn fold_motion(
5344        from: Position,
5345        count: Option<u32>,
5346        forward: bool,
5347        folds: Option<&OpenFolds>,
5348    ) -> Position {
5349        let text: String = (1..=14).map(|n| format!("line {n}\n")).collect();
5350        let (registry, b, mut doc) = fixture(&text);
5351        let id = if forward {
5352            b.goto_next_fold
5353        } else {
5354            b.goto_prev_fold
5355        };
5356        let mut inv = CommandInvocation::of(id.0);
5357        if let Some(n) = count {
5358            inv = inv.with_count(crate::command::Count(n));
5359        }
5360        let env = crate::registry::GrammarEnv {
5361            fold_resolver: folds.map(|f| f as &dyn crate::registry::FoldResolver),
5362            ..Default::default()
5363        };
5364        let effect = crate::dispatcher::execute_with_env(
5365            &registry,
5366            &mut doc,
5367            lattice_core::BufferId(0),
5368            from,
5369            inv,
5370            &CancellationToken::never(),
5371            env,
5372        )
5373        .expect("zj / zk dispatch");
5374        match effect {
5375            Effect::CursorMove(pos) => pos,
5376            other => panic!("expected CursorMove, got {other:?}"),
5377        }
5378    }
5379
5380    /// vim: `zj` from 1,3 → 4,1; `2zj` → 9,1; `zk` from 12,3 → 10,1.
5381    #[test]
5382    fn zj_and_zk_step_to_fold_edges_at_column_zero() {
5383        let folds = vim_folds();
5384        assert_eq!(
5385            fold_motion(Position::new(0, 2), None, true, Some(&folds)),
5386            Position::new(3, 0)
5387        );
5388        assert_eq!(
5389            fold_motion(Position::new(0, 2), Some(2), true, Some(&folds)),
5390            Position::new(8, 0)
5391        );
5392        assert_eq!(
5393            fold_motion(Position::new(11, 2), None, false, Some(&folds)),
5394            Position::new(9, 0)
5395        );
5396    }
5397
5398    /// vim: `zj` with no fold ahead leaves the cursor where it is. So does a
5399    /// caller with no fold table at all.
5400    #[test]
5401    fn zj_without_an_edge_stays_put() {
5402        let folds = vim_folds();
5403        assert_eq!(
5404            fold_motion(Position::new(11, 2), None, true, Some(&folds)),
5405            Position::new(11, 2)
5406        );
5407        assert_eq!(
5408            fold_motion(Position::new(0, 2), None, true, None),
5409            Position::new(0, 2)
5410        );
5411    }
5412
5413    /// A count larger than the folds available stops at the last edge reached.
5414    #[test]
5415    fn a_count_past_the_last_fold_stops_at_the_last_edge() {
5416        let folds = vim_folds();
5417        assert_eq!(
5418            fold_motion(Position::new(0, 2), Some(9), true, Some(&folds)),
5419            Position::new(8, 0)
5420        );
5421    }
5422
5423    fn repro_word_motion_punct(
5424        select: impl Fn(&Builtins) -> MotionId,
5425        text: &str,
5426        from: Position,
5427    ) -> Position {
5428        let (registry, b, mut doc) = fixture(text);
5429        let inv = CommandInvocation::of(select(&b).0);
5430        let effect = execute(
5431            &registry,
5432            &mut doc,
5433            lattice_core::BufferId(0),
5434            from,
5435            inv,
5436            &CancellationToken::never(),
5437        )
5438        .unwrap();
5439        match effect {
5440            Effect::CursorMove(pos) => pos,
5441            other => panic!("expected CursorMove, got {other:?}"),
5442        }
5443    }
5444
5445    #[test]
5446    fn word_forward_stops_at_punctuation() {
5447        // vim `w`: punctuation is its own word. `foo.bar` from `f` -> `.` (byte 3).
5448        assert_eq!(
5449            repro_word_motion_punct(|b| b.word_forward, "foo.bar", Position::ZERO),
5450            Position::new(0, 3),
5451        );
5452    }
5453
5454    #[test]
5455    fn word_backward_stops_at_punctuation() {
5456        // vim `b`: from `bar` start (byte 4) -> `.` start (byte 3).
5457        assert_eq!(
5458            repro_word_motion_punct(|b| b.word_backward, "foo.bar", Position::new(0, 4)),
5459            Position::new(0, 3),
5460        );
5461    }
5462
5463    #[test]
5464    fn word_end_stops_at_punctuation() {
5465        // vim `e`: from `f` (byte 0) lands at the end of `foo` (byte 2); a
5466        // second `e` (from byte 2) lands on the one-char `.` word (byte 3).
5467        assert_eq!(
5468            repro_word_motion_punct(|b| b.word_end, "foo.bar", Position::ZERO),
5469            Position::new(0, 2),
5470        );
5471        assert_eq!(
5472            repro_word_motion_punct(|b| b.word_end, "foo.bar", Position::new(0, 2)),
5473            Position::new(0, 3),
5474        );
5475    }
5476
5477    #[test]
5478    fn word_forward_advances_to_next_word_start() {
5479        let (registry, b, mut doc) = fixture("hello world");
5480        let inv = CommandInvocation::of(b.word_forward.0);
5481        let effect = execute(
5482            &registry,
5483            &mut doc,
5484            lattice_core::BufferId(0),
5485            Position::ZERO,
5486            inv,
5487            &CancellationToken::never(),
5488        )
5489        .unwrap();
5490        match effect {
5491            Effect::CursorMove(pos) => {
5492                assert_eq!(pos, Position::new(0, 6));
5493            }
5494            other => panic!("expected CursorMove, got {other:?}"),
5495        }
5496    }
5497
5498    #[test]
5499    fn word_forward_with_count_advances_by_count() {
5500        let (registry, b, mut doc) = fixture("one two three four");
5501        let inv = CommandInvocation::of(b.word_forward.0).with_count(Count(2));
5502        let effect = execute(
5503            &registry,
5504            &mut doc,
5505            lattice_core::BufferId(0),
5506            Position::ZERO,
5507            inv,
5508            &CancellationToken::never(),
5509        )
5510        .unwrap();
5511        match effect {
5512            Effect::CursorMove(pos) => {
5513                // "one two THREE four" -- two words forward from origin lands
5514                // at the start of "three" (byte 8).
5515                assert_eq!(pos, Position::new(0, 8));
5516            }
5517            other => panic!("expected CursorMove, got {other:?}"),
5518        }
5519    }
5520
5521    #[test]
5522    fn word_forward_across_newline() {
5523        let (registry, b, mut doc) = fixture("hello\nworld");
5524        let inv = CommandInvocation::of(b.word_forward.0);
5525        let effect = execute(
5526            &registry,
5527            &mut doc,
5528            lattice_core::BufferId(0),
5529            Position::ZERO,
5530            inv,
5531            &CancellationToken::never(),
5532        )
5533        .unwrap();
5534        match effect {
5535            Effect::CursorMove(pos) => {
5536                assert_eq!(pos, Position::new(1, 0));
5537            }
5538            other => panic!("expected CursorMove, got {other:?}"),
5539        }
5540    }
5541
5542    #[test]
5543    fn delete_with_word_forward_target_dw_semantics() {
5544        // The classic `dw`: from origin, delete to next word start.
5545        // Delete now also yanks the deleted content into the unnamed
5546        // register, so the Effect is `Many([Edits, Yank])`.
5547        let (registry, b, mut doc) = fixture("hello world");
5548        let inv = CommandInvocation::of(b.delete.0)
5549            .with_target(Target::Motion(b.word_forward, crate::args::Args::None));
5550        let effect = execute(
5551            &registry,
5552            &mut doc,
5553            lattice_core::BufferId(0),
5554            Position::ZERO,
5555            inv,
5556            &CancellationToken::never(),
5557        )
5558        .unwrap();
5559        match effect {
5560            Effect::Many(parts) => {
5561                assert_eq!(parts.len(), 2);
5562                match &parts[0] {
5563                    Effect::Edits(applied) => {
5564                        assert_eq!(applied.len(), 1);
5565                        assert_eq!(applied[0].replaced_text, "hello ");
5566                    }
5567                    other => panic!("expected Edits at [0], got {other:?}"),
5568                }
5569                match &parts[1] {
5570                    Effect::Yank { content, kind, .. } => {
5571                        assert_eq!(content, "hello ");
5572                        assert_eq!(*kind, YankKind::Charwise);
5573                    }
5574                    other => panic!("expected Yank at [1], got {other:?}"),
5575                }
5576            }
5577            other => panic!("expected Many, got {other:?}"),
5578        }
5579        assert_eq!(doc.text(), "world");
5580    }
5581
5582    #[test]
5583    fn delete_with_explicit_whole_range_deletes_buffer_and_yanks_linewise() {
5584        let (registry, b, mut doc) = fixture("a\nb\nc");
5585        let inv = CommandInvocation::of(b.delete.0).with_range(crate::range::Range::Whole);
5586        let effect = execute(
5587            &registry,
5588            &mut doc,
5589            lattice_core::BufferId(0),
5590            Position::ZERO,
5591            inv,
5592            &CancellationToken::never(),
5593        )
5594        .unwrap();
5595        match effect {
5596            Effect::Many(parts) => {
5597                assert!(matches!(parts[0], Effect::Edits(_)));
5598                match &parts[1] {
5599                    Effect::Yank { kind, .. } => assert_eq!(*kind, YankKind::Linewise),
5600                    other => panic!("expected Yank at [1], got {other:?}"),
5601                }
5602            }
5603            other => panic!("expected Many, got {other:?}"),
5604        }
5605        assert_eq!(doc.text(), "");
5606    }
5607
5608    #[test]
5609    fn delete_with_current_line_range_clears_just_that_line() {
5610        let (registry, b, mut doc) = fixture("aaa\nBBB\nccc");
5611        let cursor = Position::new(1, 0);
5612        let inv = CommandInvocation::of(b.delete.0).with_range(crate::range::Range::CurrentLine);
5613        execute(
5614            &registry,
5615            &mut doc,
5616            lattice_core::BufferId(0),
5617            cursor,
5618            inv,
5619            &CancellationToken::never(),
5620        )
5621        .unwrap();
5622        // Slice 8.i.4.g: `dd` consumes BBB AND its trailing
5623        // newline so the line count drops by 1 (vim semantics).
5624        assert_eq!(doc.text(), "aaa\nccc");
5625    }
5626
5627    #[test]
5628    fn unknown_command_id_errors() {
5629        let (registry, _, mut doc) = fixture("abc");
5630        let bogus = lattice_protocol::ids::CommandId::new(99_999);
5631        let inv = CommandInvocation::of(bogus);
5632        assert!(matches!(
5633            execute(
5634                &registry,
5635                &mut doc,
5636                lattice_core::BufferId(0),
5637                Position::ZERO,
5638                inv,
5639                &CancellationToken::never()
5640            ),
5641            Err(CommandError::UnknownCommand)
5642        ));
5643    }
5644
5645    #[test]
5646    fn operator_without_target_or_range_errors() {
5647        let (registry, b, mut doc) = fixture("abc");
5648        let inv = CommandInvocation::of(b.delete.0);
5649        assert!(matches!(
5650            execute(
5651                &registry,
5652                &mut doc,
5653                lattice_core::BufferId(0),
5654                Position::ZERO,
5655                inv,
5656                &CancellationToken::never()
5657            ),
5658            Err(CommandError::MissingTarget)
5659        ));
5660    }
5661
5662    #[test]
5663    fn char_left_at_origin_stays_put() {
5664        let (registry, b, mut doc) = fixture("abc");
5665        let inv = CommandInvocation::of(b.char_left.0);
5666        let effect = execute(
5667            &registry,
5668            &mut doc,
5669            lattice_core::BufferId(0),
5670            Position::ZERO,
5671            inv,
5672            &CancellationToken::never(),
5673        )
5674        .unwrap();
5675        match effect {
5676            Effect::CursorMove(pos) => assert_eq!(pos, Position::ZERO),
5677            other => panic!("expected CursorMove, got {other:?}"),
5678        }
5679    }
5680
5681    #[test]
5682    fn char_right_advances_one_byte() {
5683        let (registry, b, mut doc) = fixture("abc");
5684        let inv = CommandInvocation::of(b.char_right.0);
5685        let effect = execute(
5686            &registry,
5687            &mut doc,
5688            lattice_core::BufferId(0),
5689            Position::ZERO,
5690            inv,
5691            &CancellationToken::never(),
5692        )
5693        .unwrap();
5694        match effect {
5695            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 1)),
5696            other => panic!("expected CursorMove, got {other:?}"),
5697        }
5698    }
5699
5700    #[test]
5701    fn char_right_advances_whole_multibyte_scalar() {
5702        // `│` (U+2502) is 3 bytes. char_right must land on the next scalar
5703        // boundary (byte 3), never mid-glyph (byte 1), or a subsequent delete
5704        // panics ropey on a non-char-boundary slice.
5705        let (registry, b, mut doc) = fixture("│x");
5706        let inv = CommandInvocation::of(b.char_right.0);
5707        let effect = execute(
5708            &registry,
5709            &mut doc,
5710            lattice_core::BufferId(0),
5711            Position::ZERO,
5712            inv,
5713            &CancellationToken::never(),
5714        )
5715        .unwrap();
5716        match effect {
5717            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 3)),
5718            other => panic!("expected CursorMove, got {other:?}"),
5719        }
5720    }
5721
5722    #[test]
5723    fn char_left_retreats_whole_multibyte_scalar() {
5724        // Cursor just after `│` (byte 3); char_left must return to byte 0,
5725        // not byte 2 (mid-glyph).
5726        let (registry, b, mut doc) = fixture("│x");
5727        let inv = CommandInvocation::of(b.char_left.0);
5728        let effect = execute(
5729            &registry,
5730            &mut doc,
5731            lattice_core::BufferId(0),
5732            Position::new(0, 3),
5733            inv,
5734            &CancellationToken::never(),
5735        )
5736        .unwrap();
5737        match effect {
5738            Effect::CursorMove(pos) => assert_eq!(pos, Position::ZERO),
5739            other => panic!("expected CursorMove, got {other:?}"),
5740        }
5741    }
5742
5743    #[test]
5744    fn char_right_at_end_of_line_stays_put() {
5745        let (registry, b, mut doc) = fixture("ab");
5746        let inv = CommandInvocation::of(b.char_right.0);
5747        let effect = execute(
5748            &registry,
5749            &mut doc,
5750            lattice_core::BufferId(0),
5751            Position::new(0, 2),
5752            inv,
5753            &CancellationToken::never(),
5754        )
5755        .unwrap();
5756        match effect {
5757            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 2)),
5758            other => panic!("expected CursorMove, got {other:?}"),
5759        }
5760    }
5761
5762    #[test]
5763    fn line_down_moves_one_line_and_clamps_byte() {
5764        let (registry, b, mut doc) = fixture("hello\nhi");
5765        let inv = CommandInvocation::of(b.line_down.0);
5766        let effect = execute(
5767            &registry,
5768            &mut doc,
5769            lattice_core::BufferId(0),
5770            Position::new(0, 5),
5771            inv,
5772            &CancellationToken::never(),
5773        )
5774        .unwrap();
5775        match effect {
5776            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(1, 2)),
5777            other => panic!("expected CursorMove, got {other:?}"),
5778        }
5779    }
5780
5781    /// VM.3L: `k` on the first line fails (vim beeps) instead of returning
5782    /// the cursor, so `dk` there can't delete the line it's on.
5783    #[test]
5784    fn line_up_at_top_fails() {
5785        let (registry, b, mut doc) = fixture("a\nb");
5786        let inv = CommandInvocation::of(b.line_up.0);
5787        let result = execute(
5788            &registry,
5789            &mut doc,
5790            lattice_core::BufferId(0),
5791            Position::ZERO,
5792            inv,
5793            &CancellationToken::never(),
5794        );
5795        assert!(
5796            matches!(result, Err(CommandError::MotionFailed)),
5797            "got {result:?}"
5798        );
5799    }
5800
5801    /// And `j` on the last line.
5802    #[test]
5803    fn line_down_on_the_last_line_fails() {
5804        let (registry, b, mut doc) = fixture("a\nb");
5805        let inv = CommandInvocation::of(b.line_down.0);
5806        let result = execute(
5807            &registry,
5808            &mut doc,
5809            lattice_core::BufferId(0),
5810            Position::new(1, 0),
5811            inv,
5812            &CancellationToken::never(),
5813        );
5814        assert!(
5815            matches!(result, Err(CommandError::MotionFailed)),
5816            "got {result:?}"
5817        );
5818    }
5819
5820    #[test]
5821    fn line_start_resets_byte_to_zero() {
5822        let (registry, b, mut doc) = fixture("hello world");
5823        let inv = CommandInvocation::of(b.line_start.0);
5824        let effect = execute(
5825            &registry,
5826            &mut doc,
5827            lattice_core::BufferId(0),
5828            Position::new(0, 7),
5829            inv,
5830            &CancellationToken::never(),
5831        )
5832        .unwrap();
5833        match effect {
5834            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 0)),
5835            other => panic!("expected CursorMove, got {other:?}"),
5836        }
5837    }
5838
5839    #[test]
5840    fn line_end_jumps_to_line_byte_length() {
5841        let (registry, b, mut doc) = fixture("hello world");
5842        let inv = CommandInvocation::of(b.line_end.0);
5843        let effect = execute(
5844            &registry,
5845            &mut doc,
5846            lattice_core::BufferId(0),
5847            Position::ZERO,
5848            inv,
5849            &CancellationToken::never(),
5850        )
5851        .unwrap();
5852        match effect {
5853            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 11)),
5854            other => panic!("expected CursorMove, got {other:?}"),
5855        }
5856    }
5857
5858    #[test]
5859    fn goto_first_line_returns_to_origin() {
5860        let (registry, b, mut doc) = fixture("a\nb\nc");
5861        let inv = CommandInvocation::of(b.goto_first_line.0);
5862        let effect = execute(
5863            &registry,
5864            &mut doc,
5865            lattice_core::BufferId(0),
5866            Position::new(2, 0),
5867            inv,
5868            &CancellationToken::never(),
5869        )
5870        .unwrap();
5871        match effect {
5872            Effect::CursorMove(pos) => assert_eq!(pos, Position::ZERO),
5873            other => panic!("expected CursorMove, got {other:?}"),
5874        }
5875    }
5876
5877    #[test]
5878    fn goto_last_line_jumps_to_last_addressable_line() {
5879        let (registry, b, mut doc) = fixture("a\nb\nc");
5880        let inv = CommandInvocation::of(b.goto_last_line.0);
5881        let effect = execute(
5882            &registry,
5883            &mut doc,
5884            lattice_core::BufferId(0),
5885            Position::ZERO,
5886            inv,
5887            &CancellationToken::never(),
5888        )
5889        .unwrap();
5890        match effect {
5891            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(2, 0)),
5892            other => panic!("expected CursorMove, got {other:?}"),
5893        }
5894    }
5895
5896    #[test]
5897    fn goto_last_line_with_count_goes_to_specific_line() {
5898        // `3G` on a 5-line buffer → line 3 (1-indexed → row 2)
5899        let (registry, b, mut doc) = fixture("a\nb\nc\nd\ne");
5900        let inv = CommandInvocation::of(b.goto_last_line.0).with_count(Count(3));
5901        let effect = execute(
5902            &registry,
5903            &mut doc,
5904            lattice_core::BufferId(0),
5905            Position::ZERO,
5906            inv,
5907            &CancellationToken::never(),
5908        )
5909        .unwrap();
5910        match effect {
5911            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(2, 0)),
5912            other => panic!("expected CursorMove, got {other:?}"),
5913        }
5914    }
5915
5916    #[test]
5917    fn goto_first_line_with_count_goes_to_specific_line() {
5918        // `3gg` on a 5-line buffer → line 3 (1-indexed → row 2)
5919        let (registry, b, mut doc) = fixture("a\nb\nc\nd\ne");
5920        let inv = CommandInvocation::of(b.goto_first_line.0).with_count(Count(3));
5921        let effect = execute(
5922            &registry,
5923            &mut doc,
5924            lattice_core::BufferId(0),
5925            Position::new(4, 0),
5926            inv,
5927            &CancellationToken::never(),
5928        )
5929        .unwrap();
5930        match effect {
5931            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(2, 0)),
5932            other => panic!("expected CursorMove, got {other:?}"),
5933        }
5934    }
5935
5936    #[test]
5937    fn x_semantics_via_delete_with_char_right_target() {
5938        // Vim's `x`: delete the char under the cursor (and yank it).
5939        let (registry, b, mut doc) = fixture("hello");
5940        let inv = CommandInvocation::of(b.delete.0)
5941            .with_target(Target::Motion(b.char_right, crate::args::Args::None));
5942        let effect = execute(
5943            &registry,
5944            &mut doc,
5945            lattice_core::BufferId(0),
5946            Position::ZERO,
5947            inv,
5948            &CancellationToken::never(),
5949        )
5950        .unwrap();
5951        match effect {
5952            Effect::Many(parts) => {
5953                assert!(matches!(parts[0], Effect::Edits(_)));
5954                assert!(matches!(parts[1], Effect::Yank { .. }));
5955            }
5956            other => panic!("expected Many, got {other:?}"),
5957        }
5958        assert_eq!(doc.text(), "ello");
5959    }
5960
5961    // ---- word-backward (b) ----
5962
5963    #[test]
5964    fn word_backward_from_mid_word_lands_at_start_of_word() {
5965        let (registry, b, mut doc) = fixture("hello world");
5966        // Cursor on 'r' of "world" (byte 8) -- vim's `b` lands on 'w' (byte 6).
5967        let inv = CommandInvocation::of(b.word_backward.0);
5968        let effect = execute(
5969            &registry,
5970            &mut doc,
5971            lattice_core::BufferId(0),
5972            Position::new(0, 8),
5973            inv,
5974            &CancellationToken::never(),
5975        )
5976        .unwrap();
5977        match effect {
5978            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 6)),
5979            other => panic!("expected CursorMove, got {other:?}"),
5980        }
5981    }
5982
5983    #[test]
5984    fn word_backward_from_start_of_word_lands_at_start_of_previous_word() {
5985        let (registry, b, mut doc) = fixture("one two three");
5986        // Cursor on 't' of "three" (byte 8). `b` -> 't' of "two" (byte 4).
5987        let inv = CommandInvocation::of(b.word_backward.0);
5988        let effect = execute(
5989            &registry,
5990            &mut doc,
5991            lattice_core::BufferId(0),
5992            Position::new(0, 8),
5993            inv,
5994            &CancellationToken::never(),
5995        )
5996        .unwrap();
5997        match effect {
5998            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 4)),
5999            other => panic!("expected CursorMove, got {other:?}"),
6000        }
6001    }
6002
6003    #[test]
6004    fn word_backward_at_origin_stays_put() {
6005        let (registry, b, mut doc) = fixture("hello");
6006        let inv = CommandInvocation::of(b.word_backward.0);
6007        let effect = execute(
6008            &registry,
6009            &mut doc,
6010            lattice_core::BufferId(0),
6011            Position::ZERO,
6012            inv,
6013            &CancellationToken::never(),
6014        )
6015        .unwrap();
6016        match effect {
6017            Effect::CursorMove(pos) => assert_eq!(pos, Position::ZERO),
6018            other => panic!("expected CursorMove, got {other:?}"),
6019        }
6020    }
6021
6022    #[test]
6023    fn word_backward_crosses_newlines() {
6024        let (registry, b, mut doc) = fixture("foo\nbar");
6025        // Cursor on 'b' (line 1 byte 0). `b` -> 'f' (line 0 byte 0).
6026        let inv = CommandInvocation::of(b.word_backward.0);
6027        let effect = execute(
6028            &registry,
6029            &mut doc,
6030            lattice_core::BufferId(0),
6031            Position::new(1, 0),
6032            inv,
6033            &CancellationToken::never(),
6034        )
6035        .unwrap();
6036        match effect {
6037            Effect::CursorMove(pos) => assert_eq!(pos, Position::ZERO),
6038            other => panic!("expected CursorMove, got {other:?}"),
6039        }
6040    }
6041
6042    #[test]
6043    fn word_backward_with_count_jumps_count_words() {
6044        let (registry, b, mut doc) = fixture("one two three four");
6045        // Cursor on 'f' of "four" (byte 14). `2b` -> 't' of "two" (byte 4).
6046        let inv = CommandInvocation::of(b.word_backward.0).with_count(Count(2));
6047        let effect = execute(
6048            &registry,
6049            &mut doc,
6050            lattice_core::BufferId(0),
6051            Position::new(0, 14),
6052            inv,
6053            &CancellationToken::never(),
6054        )
6055        .unwrap();
6056        match effect {
6057            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 4)),
6058            other => panic!("expected CursorMove, got {other:?}"),
6059        }
6060    }
6061
6062    #[test]
6063    fn word_backward_lands_on_punctuation_word() {
6064        let (registry, b, mut doc) = fixture("alpha, beta");
6065        // Cursor on 'b' of "beta" (byte 7). vim `b` -> the `,` (byte 5): the
6066        // comma is its own word, so `b` lands on it rather than skipping to
6067        // "alpha". (Pre-3-class this wrongly returned byte 0.)
6068        let inv = CommandInvocation::of(b.word_backward.0);
6069        let effect = execute(
6070            &registry,
6071            &mut doc,
6072            lattice_core::BufferId(0),
6073            Position::new(0, 7),
6074            inv,
6075            &CancellationToken::never(),
6076        )
6077        .unwrap();
6078        match effect {
6079            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 5)),
6080            other => panic!("expected CursorMove, got {other:?}"),
6081        }
6082    }
6083
6084    // ---- word-end (e) ----
6085
6086    #[test]
6087    fn word_end_from_start_of_word_lands_at_last_byte_of_word() {
6088        let (registry, b, mut doc) = fixture("hello world");
6089        // From 'h' (byte 0) `e` -> 'o' of "hello" (byte 4).
6090        let inv = CommandInvocation::of(b.word_end.0);
6091        let effect = execute(
6092            &registry,
6093            &mut doc,
6094            lattice_core::BufferId(0),
6095            Position::ZERO,
6096            inv,
6097            &CancellationToken::never(),
6098        )
6099        .unwrap();
6100        match effect {
6101            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 4)),
6102            other => panic!("expected CursorMove, got {other:?}"),
6103        }
6104    }
6105
6106    #[test]
6107    fn word_end_from_end_of_word_lands_at_end_of_next_word() {
6108        let (registry, b, mut doc) = fixture("hello world");
6109        // From 'o' of "hello" (byte 4) `e` -> 'd' of "world" (byte 10).
6110        let inv = CommandInvocation::of(b.word_end.0);
6111        let effect = execute(
6112            &registry,
6113            &mut doc,
6114            lattice_core::BufferId(0),
6115            Position::new(0, 4),
6116            inv,
6117            &CancellationToken::never(),
6118        )
6119        .unwrap();
6120        match effect {
6121            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 10)),
6122            other => panic!("expected CursorMove, got {other:?}"),
6123        }
6124    }
6125
6126    #[test]
6127    fn word_end_at_buffer_end_stays_put() {
6128        let (registry, b, mut doc) = fixture("hi");
6129        // From 'i' (byte 1) `e` -> stays at byte 1 (no further word).
6130        let inv = CommandInvocation::of(b.word_end.0);
6131        let effect = execute(
6132            &registry,
6133            &mut doc,
6134            lattice_core::BufferId(0),
6135            Position::new(0, 1),
6136            inv,
6137            &CancellationToken::never(),
6138        )
6139        .unwrap();
6140        match effect {
6141            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 1)),
6142            other => panic!("expected CursorMove, got {other:?}"),
6143        }
6144    }
6145
6146    #[test]
6147    fn word_end_crosses_newlines() {
6148        let (registry, b, mut doc) = fixture("foo\nbar");
6149        // From 'o' of "foo" (line 0 byte 2) `e` -> 'r' of "bar" (line 1 byte 2).
6150        let inv = CommandInvocation::of(b.word_end.0);
6151        let effect = execute(
6152            &registry,
6153            &mut doc,
6154            lattice_core::BufferId(0),
6155            Position::new(0, 2),
6156            inv,
6157            &CancellationToken::never(),
6158        )
6159        .unwrap();
6160        match effect {
6161            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(1, 2)),
6162            other => panic!("expected CursorMove, got {other:?}"),
6163        }
6164    }
6165
6166    #[test]
6167    fn word_end_with_count_advances_by_count() {
6168        let (registry, b, mut doc) = fixture("one two three four");
6169        // From 'o' (byte 0) `2e` -> end of "two" = 'o' of "two" (byte 6).
6170        let inv = CommandInvocation::of(b.word_end.0).with_count(Count(2));
6171        let effect = execute(
6172            &registry,
6173            &mut doc,
6174            lattice_core::BufferId(0),
6175            Position::ZERO,
6176            inv,
6177            &CancellationToken::never(),
6178        )
6179        .unwrap();
6180        match effect {
6181            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 6)),
6182            other => panic!("expected CursorMove, got {other:?}"),
6183        }
6184    }
6185
6186    // ---- first-non-blank (^) ----
6187
6188    #[test]
6189    fn first_non_blank_skips_leading_spaces() {
6190        let (registry, b, mut doc) = fixture("    hello");
6191        let inv = CommandInvocation::of(b.first_non_blank.0);
6192        let effect = execute(
6193            &registry,
6194            &mut doc,
6195            lattice_core::BufferId(0),
6196            Position::ZERO,
6197            inv,
6198            &CancellationToken::never(),
6199        )
6200        .unwrap();
6201        match effect {
6202            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 4)),
6203            other => panic!("expected CursorMove, got {other:?}"),
6204        }
6205    }
6206
6207    #[test]
6208    fn first_non_blank_skips_leading_tabs() {
6209        let (registry, b, mut doc) = fixture("\t\thello");
6210        let inv = CommandInvocation::of(b.first_non_blank.0);
6211        let effect = execute(
6212            &registry,
6213            &mut doc,
6214            lattice_core::BufferId(0),
6215            Position::ZERO,
6216            inv,
6217            &CancellationToken::never(),
6218        )
6219        .unwrap();
6220        match effect {
6221            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 2)),
6222            other => panic!("expected CursorMove, got {other:?}"),
6223        }
6224    }
6225
6226    #[test]
6227    fn first_non_blank_on_already_non_blank_line_returns_zero() {
6228        let (registry, b, mut doc) = fixture("hello");
6229        let inv = CommandInvocation::of(b.first_non_blank.0);
6230        let effect = execute(
6231            &registry,
6232            &mut doc,
6233            lattice_core::BufferId(0),
6234            Position::new(0, 3),
6235            inv,
6236            &CancellationToken::never(),
6237        )
6238        .unwrap();
6239        match effect {
6240            Effect::CursorMove(pos) => assert_eq!(pos, Position::ZERO),
6241            other => panic!("expected CursorMove, got {other:?}"),
6242        }
6243    }
6244
6245    #[test]
6246    fn first_non_blank_on_blank_only_line_returns_end() {
6247        let (registry, b, mut doc) = fixture("    ");
6248        let inv = CommandInvocation::of(b.first_non_blank.0);
6249        let effect = execute(
6250            &registry,
6251            &mut doc,
6252            lattice_core::BufferId(0),
6253            Position::ZERO,
6254            inv,
6255            &CancellationToken::never(),
6256        )
6257        .unwrap();
6258        match effect {
6259            Effect::CursorMove(pos) => {
6260                // No non-blank chars; cursor lands at end of line (byte 4).
6261                assert_eq!(pos, Position::new(0, 4));
6262            }
6263            other => panic!("expected CursorMove, got {other:?}"),
6264        }
6265    }
6266
6267    #[test]
6268    fn first_non_blank_respects_current_line() {
6269        let (registry, b, mut doc) = fixture("a\n  bc");
6270        // Cursor on line 1; first non-blank is at byte 2 of line 1.
6271        let inv = CommandInvocation::of(b.first_non_blank.0);
6272        let effect = execute(
6273            &registry,
6274            &mut doc,
6275            lattice_core::BufferId(0),
6276            Position::new(1, 4),
6277            inv,
6278            &CancellationToken::never(),
6279        )
6280        .unwrap();
6281        match effect {
6282            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(1, 2)),
6283            other => panic!("expected CursorMove, got {other:?}"),
6284        }
6285    }
6286
6287    // ---- delete + new motions composition ----
6288
6289    #[test]
6290    fn delete_with_word_backward_target_db_semantics() {
6291        // `db` from past-EOL deletes the last word: word_backward from byte 11
6292        // lands at byte 6 (start of "world"); the [6, 11) range covers "world".
6293        let (registry, b, mut doc) = fixture("hello world");
6294        let inv = CommandInvocation::of(b.delete.0)
6295            .with_target(Target::Motion(b.word_backward, crate::args::Args::None));
6296        execute(
6297            &registry,
6298            &mut doc,
6299            lattice_core::BufferId(0),
6300            Position::new(0, 11),
6301            inv,
6302            &CancellationToken::never(),
6303        )
6304        .unwrap();
6305        assert_eq!(doc.text(), "hello ");
6306    }
6307
6308    // ---- Indent operators (>, <) ----
6309
6310    #[test]
6311    fn indent_right_current_line_prepends_four_spaces() {
6312        let (registry, b, mut doc) = fixture("hello");
6313        let inv =
6314            CommandInvocation::of(b.indent_right.0).with_range(crate::range::Range::CurrentLine);
6315        execute(
6316            &registry,
6317            &mut doc,
6318            lattice_core::BufferId(0),
6319            Position::ZERO,
6320            inv,
6321            &CancellationToken::never(),
6322        )
6323        .unwrap();
6324        assert_eq!(doc.text(), "    hello");
6325    }
6326
6327    #[test]
6328    fn indent_left_current_line_strips_four_spaces() {
6329        let (registry, b, mut doc) = fixture("    hello");
6330        let inv =
6331            CommandInvocation::of(b.indent_left.0).with_range(crate::range::Range::CurrentLine);
6332        execute(
6333            &registry,
6334            &mut doc,
6335            lattice_core::BufferId(0),
6336            Position::ZERO,
6337            inv,
6338            &CancellationToken::never(),
6339        )
6340        .unwrap();
6341        assert_eq!(doc.text(), "hello");
6342    }
6343
6344    #[test]
6345    fn indent_left_strips_partial_indent() {
6346        let (registry, b, mut doc) = fixture("  hello");
6347        let inv =
6348            CommandInvocation::of(b.indent_left.0).with_range(crate::range::Range::CurrentLine);
6349        execute(
6350            &registry,
6351            &mut doc,
6352            lattice_core::BufferId(0),
6353            Position::ZERO,
6354            inv,
6355            &CancellationToken::never(),
6356        )
6357        .unwrap();
6358        // Only 2 spaces present; strips both.
6359        assert_eq!(doc.text(), "hello");
6360    }
6361
6362    #[test]
6363    fn indent_left_strips_leading_tab() {
6364        let (registry, b, mut doc) = fixture("\thello");
6365        let inv =
6366            CommandInvocation::of(b.indent_left.0).with_range(crate::range::Range::CurrentLine);
6367        execute(
6368            &registry,
6369            &mut doc,
6370            lattice_core::BufferId(0),
6371            Position::ZERO,
6372            inv,
6373            &CancellationToken::never(),
6374        )
6375        .unwrap();
6376        assert_eq!(doc.text(), "hello");
6377    }
6378
6379    #[test]
6380    fn indent_left_no_indent_is_no_op() {
6381        let (registry, b, mut doc) = fixture("hello");
6382        let inv =
6383            CommandInvocation::of(b.indent_left.0).with_range(crate::range::Range::CurrentLine);
6384        execute(
6385            &registry,
6386            &mut doc,
6387            lattice_core::BufferId(0),
6388            Position::ZERO,
6389            inv,
6390            &CancellationToken::never(),
6391        )
6392        .unwrap();
6393        assert_eq!(doc.text(), "hello");
6394    }
6395
6396    #[test]
6397    fn indent_right_with_whole_range_indents_every_line() {
6398        let (registry, b, mut doc) = fixture("a\nb\nc");
6399        let inv = CommandInvocation::of(b.indent_right.0).with_range(crate::range::Range::Whole);
6400        execute(
6401            &registry,
6402            &mut doc,
6403            lattice_core::BufferId(0),
6404            Position::ZERO,
6405            inv,
6406            &CancellationToken::never(),
6407        )
6408        .unwrap();
6409        assert_eq!(doc.text(), "    a\n    b\n    c");
6410    }
6411
6412    // IN.0: the indent unit is resolved by the host and injected via
6413    // the env. These drive `>` / `<` at non-default units, which is the
6414    // whole point of the slice -- the tests above all run at the
6415    // registered defaults (shiftwidth=4, expandtab) and would pass
6416    // against the old hardcoded `"    "` too.
6417    fn indent_shift(text: &str, unit: lattice_core::IndentUnit, right: bool) -> String {
6418        let (registry, b, mut doc) = fixture(text);
6419        let op = if right { b.indent_right } else { b.indent_left };
6420        let inv = CommandInvocation::of(op.0).with_range(crate::range::Range::Whole);
6421        crate::execute_with_env(
6422            &registry,
6423            &mut doc,
6424            lattice_core::BufferId(0),
6425            Position::ZERO,
6426            inv,
6427            &CancellationToken::never(),
6428            crate::registry::GrammarEnv {
6429                indent: unit,
6430                ..Default::default()
6431            },
6432        )
6433        .unwrap();
6434        doc.text()
6435    }
6436
6437    #[test]
6438    fn indent_right_honours_shiftwidth() {
6439        let sw2 = lattice_core::IndentUnit::new(2, true, 4);
6440        assert_eq!(indent_shift("a", sw2, true), "  a");
6441        let sw8 = lattice_core::IndentUnit::new(8, true, 4);
6442        assert_eq!(indent_shift("a", sw8, true), "        a");
6443    }
6444
6445    #[test]
6446    fn indent_right_honours_noexpandtab() {
6447        // shiftwidth 4, tabstop 4, tabs: one level is one tab.
6448        let tabs = lattice_core::IndentUnit::new(4, false, 4);
6449        assert_eq!(indent_shift("a", tabs, true), "\ta");
6450        // Two levels of 4 at tabstop 8 is one tab, not two.
6451        let tabs8 = lattice_core::IndentUnit::new(4, false, 8);
6452        assert_eq!(indent_shift("    a", tabs8, true), "\ta");
6453    }
6454
6455    #[test]
6456    fn a_tab_and_tabstop_spaces_dedent_identically() {
6457        // The defect the byte-splice model had: `<` stripped a whole
6458        // tab but only `shiftwidth` spaces, so these two visually
6459        // identical lines dedented by different amounts.
6460        let u = lattice_core::IndentUnit::new(2, true, 4);
6461        assert_eq!(indent_shift("\ta", u, false), "  a");
6462        assert_eq!(indent_shift("    a", u, false), "  a");
6463    }
6464
6465    #[test]
6466    fn indent_right_leaves_blank_lines_alone() {
6467        // Vim does not indent blank lines, and doing so parks trailing
6468        // whitespace on lines the user never typed into.
6469        let u = lattice_core::IndentUnit::default();
6470        assert_eq!(indent_shift("a\n\nb", u, true), "    a\n\n    b");
6471        assert_eq!(indent_shift("a\n   \nb", u, true), "    a\n   \n    b");
6472    }
6473
6474    #[test]
6475    fn dedent_at_column_zero_emits_no_edit() {
6476        // Not merely "no visible change" -- no edit at all, so `<` on
6477        // an unindented block costs no undo entry.
6478        let (registry, b, mut doc) = fixture("a\nb");
6479        let inv = CommandInvocation::of(b.indent_left.0).with_range(crate::range::Range::Whole);
6480        let eff = execute(
6481            &registry,
6482            &mut doc,
6483            lattice_core::BufferId(0),
6484            Position::ZERO,
6485            inv,
6486            &CancellationToken::never(),
6487        )
6488        .unwrap();
6489        assert!(matches!(eff, Effect::None), "expected no edit, got {eff:?}");
6490        assert_eq!(doc.text(), "a\nb");
6491    }
6492
6493    #[test]
6494    fn indent_left_with_whole_range_dedents_every_line() {
6495        let (registry, b, mut doc) = fixture("    a\n    b\n    c");
6496        let inv = CommandInvocation::of(b.indent_left.0).with_range(crate::range::Range::Whole);
6497        execute(
6498            &registry,
6499            &mut doc,
6500            lattice_core::BufferId(0),
6501            Position::ZERO,
6502            inv,
6503            &CancellationToken::never(),
6504        )
6505        .unwrap();
6506        assert_eq!(doc.text(), "a\nb\nc");
6507    }
6508
6509    // ---- Case operators (gU, gu, g~) ----
6510
6511    #[test]
6512    fn upper_uppercases_word_target() {
6513        let (registry, b, mut doc) = fixture("hello world");
6514        let inv = CommandInvocation::of(b.upper.0)
6515            .with_target(Target::Motion(b.word_forward, crate::args::Args::None));
6516        execute(
6517            &registry,
6518            &mut doc,
6519            lattice_core::BufferId(0),
6520            Position::ZERO,
6521            inv,
6522            &CancellationToken::never(),
6523        )
6524        .unwrap();
6525        // word_forward from 0 lands at byte 6 -> [0, 6) = "hello " -> "HELLO ".
6526        assert_eq!(doc.text(), "HELLO world");
6527    }
6528
6529    #[test]
6530    fn lower_lowercases_range() {
6531        let (registry, b, mut doc) = fixture("HELLO WORLD");
6532        let inv = CommandInvocation::of(b.lower.0).with_range(crate::range::Range::Whole);
6533        execute(
6534            &registry,
6535            &mut doc,
6536            lattice_core::BufferId(0),
6537            Position::ZERO,
6538            inv,
6539            &CancellationToken::never(),
6540        )
6541        .unwrap();
6542        assert_eq!(doc.text(), "hello world");
6543    }
6544
6545    #[test]
6546    fn toggle_case_inverts_each_letter() {
6547        let (registry, b, mut doc) = fixture("Hello World");
6548        let inv = CommandInvocation::of(b.toggle_case.0).with_range(crate::range::Range::Whole);
6549        execute(
6550            &registry,
6551            &mut doc,
6552            lattice_core::BufferId(0),
6553            Position::ZERO,
6554            inv,
6555            &CancellationToken::never(),
6556        )
6557        .unwrap();
6558        assert_eq!(doc.text(), "hELLO wORLD");
6559    }
6560
6561    #[test]
6562    fn upper_with_no_letters_is_no_op() {
6563        let (registry, b, mut doc) = fixture("123 !@# 456");
6564        let inv = CommandInvocation::of(b.upper.0).with_range(crate::range::Range::Whole);
6565        let effect = execute(
6566            &registry,
6567            &mut doc,
6568            lattice_core::BufferId(0),
6569            Position::ZERO,
6570            inv,
6571            &CancellationToken::never(),
6572        )
6573        .unwrap();
6574        // No transformation needed -> Effect::None.
6575        assert!(matches!(effect, Effect::None));
6576        assert_eq!(doc.text(), "123 !@# 456");
6577    }
6578
6579    #[test]
6580    fn case_operators_preserve_non_letter_bytes() {
6581        let (registry, b, mut doc) = fixture("foo_bar.baz");
6582        let inv = CommandInvocation::of(b.upper.0).with_range(crate::range::Range::Whole);
6583        execute(
6584            &registry,
6585            &mut doc,
6586            lattice_core::BufferId(0),
6587            Position::ZERO,
6588            inv,
6589            &CancellationToken::never(),
6590        )
6591        .unwrap();
6592        // Underscore and dot pass through unchanged.
6593        assert_eq!(doc.text(), "FOO_BAR.BAZ");
6594    }
6595
6596    // ---- Text objects ----
6597
6598    fn invoke_textobj(
6599        op: crate::registry::OperatorId,
6600        tobj: crate::registry::TextObjectId,
6601    ) -> CommandInvocation {
6602        CommandInvocation::of(op.0).with_target(Target::TextObject(tobj, crate::args::Args::None))
6603    }
6604
6605    #[test]
6606    fn iw_inner_word_covers_word_at_cursor() {
6607        // d iw on "hello world" with cursor on 'l' (byte 2) deletes "hello".
6608        let (registry, b, mut doc) = fixture("hello world");
6609        let inv = invoke_textobj(b.delete, b.inner_word);
6610        execute(
6611            &registry,
6612            &mut doc,
6613            lattice_core::BufferId(0),
6614            Position::new(0, 2),
6615            inv,
6616            &CancellationToken::never(),
6617        )
6618        .unwrap();
6619        assert_eq!(doc.text(), " world");
6620    }
6621
6622    #[test]
6623    fn iw_at_start_of_word_works() {
6624        let (registry, b, mut doc) = fixture("hello world");
6625        let inv = invoke_textobj(b.delete, b.inner_word);
6626        execute(
6627            &registry,
6628            &mut doc,
6629            lattice_core::BufferId(0),
6630            Position::ZERO,
6631            inv,
6632            &CancellationToken::never(),
6633        )
6634        .unwrap();
6635        assert_eq!(doc.text(), " world");
6636    }
6637
6638    #[test]
6639    fn iw_on_single_punctuation_deletes_it() {
6640        // vim `diw` on a punctuation char deletes that punctuation word.
6641        let (registry, b, mut doc) = fixture("foo.bar");
6642        let inv = invoke_textobj(b.delete, b.inner_word);
6643        // Cursor on the `.` (byte 3).
6644        execute(
6645            &registry,
6646            &mut doc,
6647            lattice_core::BufferId(0),
6648            Position::new(0, 3),
6649            inv,
6650            &CancellationToken::never(),
6651        )
6652        .unwrap();
6653        assert_eq!(doc.text(), "foobar");
6654    }
6655
6656    #[test]
6657    fn iw_on_punctuation_covers_the_whole_run() {
6658        // vim `diw` on any `.` of a punctuation run deletes the whole run.
6659        let (registry, b, mut doc) = fixture("foo...bar");
6660        let inv = invoke_textobj(b.delete, b.inner_word);
6661        // Cursor on the middle `.` (byte 4).
6662        execute(
6663            &registry,
6664            &mut doc,
6665            lattice_core::BufferId(0),
6666            Position::new(0, 4),
6667            inv,
6668            &CancellationToken::never(),
6669        )
6670        .unwrap();
6671        assert_eq!(doc.text(), "foobar");
6672    }
6673
6674    #[test]
6675    fn iw_on_whitespace_deletes_the_whitespace_run() {
6676        // vim `diw` on whitespace deletes the whitespace run (blanks are a
6677        // word class too) -- NOT a no-op.
6678        let (registry, b, mut doc) = fixture("hello world");
6679        let inv = invoke_textobj(b.delete, b.inner_word);
6680        // Cursor on the space (byte 5).
6681        execute(
6682            &registry,
6683            &mut doc,
6684            lattice_core::BufferId(0),
6685            Position::new(0, 5),
6686            inv,
6687            &CancellationToken::never(),
6688        )
6689        .unwrap();
6690        assert_eq!(doc.text(), "helloworld");
6691    }
6692
6693    #[test]
6694    fn aw_around_word_includes_trailing_whitespace() {
6695        let (registry, b, mut doc) = fixture("hello world");
6696        let inv = invoke_textobj(b.delete, b.around_word);
6697        execute(
6698            &registry,
6699            &mut doc,
6700            lattice_core::BufferId(0),
6701            Position::new(0, 2),
6702            inv,
6703            &CancellationToken::never(),
6704        )
6705        .unwrap();
6706        // around_word: "hello" + trailing space deleted -> "world".
6707        assert_eq!(doc.text(), "world");
6708    }
6709
6710    #[test]
6711    fn aw_on_last_word_takes_leading_whitespace() {
6712        let (registry, b, mut doc) = fixture("hello world");
6713        let inv = invoke_textobj(b.delete, b.around_word);
6714        // Cursor on 'w' at byte 6 -- no trailing whitespace, so leading.
6715        execute(
6716            &registry,
6717            &mut doc,
6718            lattice_core::BufferId(0),
6719            Position::new(0, 6),
6720            inv,
6721            &CancellationToken::never(),
6722        )
6723        .unwrap();
6724        assert_eq!(doc.text(), "hello");
6725    }
6726
6727    #[test]
6728    #[allow(non_snake_case)]
6729    fn iW_treats_punctuation_as_part_of_big_word() {
6730        // `iw` would split on `.`; `iW` does not because `.` is
6731        // non-whitespace -> part of the WORD.
6732        let (registry, b, mut doc) = fixture("foo.bar baz");
6733        let inv = invoke_textobj(b.delete, b.inner_big_word);
6734        execute(
6735            &registry,
6736            &mut doc,
6737            lattice_core::BufferId(0),
6738            Position::new(0, 2),
6739            inv,
6740            &CancellationToken::never(),
6741        )
6742        .unwrap();
6743        assert_eq!(doc.text(), " baz");
6744    }
6745
6746    #[test]
6747    #[allow(non_snake_case)]
6748    fn iW_on_whitespace_deletes_the_whitespace_run() {
6749        // vim `diW` on whitespace deletes the whitespace run (blanks separate
6750        // WORDs but are themselves a selectable run) -- NOT a no-op.
6751        let (registry, b, mut doc) = fixture("hello world");
6752        let inv = invoke_textobj(b.delete, b.inner_big_word);
6753        // Cursor on the space (byte 5).
6754        execute(
6755            &registry,
6756            &mut doc,
6757            lattice_core::BufferId(0),
6758            Position::new(0, 5),
6759            inv,
6760            &CancellationToken::never(),
6761        )
6762        .unwrap();
6763        assert_eq!(doc.text(), "helloworld");
6764    }
6765
6766    #[test]
6767    #[allow(non_snake_case)]
6768    fn aW_around_big_word_includes_trailing_whitespace() {
6769        let (registry, b, mut doc) = fixture("foo.bar baz");
6770        let inv = invoke_textobj(b.delete, b.around_big_word);
6771        execute(
6772            &registry,
6773            &mut doc,
6774            lattice_core::BufferId(0),
6775            Position::new(0, 0),
6776            inv,
6777            &CancellationToken::never(),
6778        )
6779        .unwrap();
6780        // around_big_word: "foo.bar" + trailing space -> "baz".
6781        assert_eq!(doc.text(), "baz");
6782    }
6783
6784    #[test]
6785    fn i_angle_covers_inside_pair() {
6786        let (registry, b, mut doc) = fixture("Vec<String>");
6787        let inv = invoke_textobj(b.delete, b.inner_angle);
6788        // Cursor inside angles (byte 5 = 'S' in "String").
6789        execute(
6790            &registry,
6791            &mut doc,
6792            lattice_core::BufferId(0),
6793            Position::new(0, 5),
6794            inv,
6795            &CancellationToken::never(),
6796        )
6797        .unwrap();
6798        assert_eq!(doc.text(), "Vec<>");
6799    }
6800
6801    #[test]
6802    fn a_angle_covers_pair_including_brackets() {
6803        let (registry, b, mut doc) = fixture("Vec<String>");
6804        let inv = invoke_textobj(b.delete, b.around_angle);
6805        execute(
6806            &registry,
6807            &mut doc,
6808            lattice_core::BufferId(0),
6809            Position::new(0, 5),
6810            inv,
6811            &CancellationToken::never(),
6812        )
6813        .unwrap();
6814        assert_eq!(doc.text(), "Vec");
6815    }
6816
6817    #[test]
6818    fn i_double_quote_covers_quoted_content_only() {
6819        let (registry, b, mut doc) = fixture(r#"foo "bar baz" qux"#);
6820        let inv = invoke_textobj(b.delete, b.inner_quote_double);
6821        // Cursor inside quotes (byte 6 = 'a' of "bar").
6822        execute(
6823            &registry,
6824            &mut doc,
6825            lattice_core::BufferId(0),
6826            Position::new(0, 6),
6827            inv,
6828            &CancellationToken::never(),
6829        )
6830        .unwrap();
6831        assert_eq!(doc.text(), r#"foo "" qux"#);
6832    }
6833
6834    #[test]
6835    fn a_double_quote_covers_quoted_content_and_quotes() {
6836        let (registry, b, mut doc) = fixture(r#"foo "bar baz" qux"#);
6837        let inv = invoke_textobj(b.delete, b.around_quote_double);
6838        execute(
6839            &registry,
6840            &mut doc,
6841            lattice_core::BufferId(0),
6842            Position::new(0, 6),
6843            inv,
6844            &CancellationToken::never(),
6845        )
6846        .unwrap();
6847        assert_eq!(doc.text(), "foo  qux");
6848    }
6849
6850    #[test]
6851    fn i_single_quote_works() {
6852        let (registry, b, mut doc) = fixture("foo 'bar' baz");
6853        let inv = invoke_textobj(b.delete, b.inner_quote_single);
6854        execute(
6855            &registry,
6856            &mut doc,
6857            lattice_core::BufferId(0),
6858            Position::new(0, 6),
6859            inv,
6860            &CancellationToken::never(),
6861        )
6862        .unwrap();
6863        assert_eq!(doc.text(), "foo '' baz");
6864    }
6865
6866    #[test]
6867    fn i_paren_covers_inside_outermost_parens() {
6868        let (registry, b, mut doc) = fixture("call(arg1, arg2)");
6869        let inv = invoke_textobj(b.delete, b.inner_paren);
6870        // Cursor on 'a' of "arg2" at byte 11.
6871        execute(
6872            &registry,
6873            &mut doc,
6874            lattice_core::BufferId(0),
6875            Position::new(0, 11),
6876            inv,
6877            &CancellationToken::never(),
6878        )
6879        .unwrap();
6880        assert_eq!(doc.text(), "call()");
6881    }
6882
6883    #[test]
6884    fn a_paren_includes_brackets() {
6885        let (registry, b, mut doc) = fixture("call(arg1, arg2)");
6886        let inv = invoke_textobj(b.delete, b.around_paren);
6887        execute(
6888            &registry,
6889            &mut doc,
6890            lattice_core::BufferId(0),
6891            Position::new(0, 11),
6892            inv,
6893            &CancellationToken::never(),
6894        )
6895        .unwrap();
6896        assert_eq!(doc.text(), "call");
6897    }
6898
6899    #[test]
6900    fn nested_parens_picks_innermost() {
6901        let (registry, b, mut doc) = fixture("a(b(c)d)e");
6902        let inv = invoke_textobj(b.delete, b.inner_paren);
6903        // Cursor at byte 4 ('c').
6904        execute(
6905            &registry,
6906            &mut doc,
6907            lattice_core::BufferId(0),
6908            Position::new(0, 4),
6909            inv,
6910            &CancellationToken::never(),
6911        )
6912        .unwrap();
6913        assert_eq!(doc.text(), "a(b()d)e");
6914    }
6915
6916    #[test]
6917    fn i_bracket_works_for_square_brackets() {
6918        let (registry, b, mut doc) = fixture("arr[1, 2, 3]");
6919        let inv = invoke_textobj(b.delete, b.inner_bracket);
6920        execute(
6921            &registry,
6922            &mut doc,
6923            lattice_core::BufferId(0),
6924            Position::new(0, 4),
6925            inv,
6926            &CancellationToken::never(),
6927        )
6928        .unwrap();
6929        assert_eq!(doc.text(), "arr[]");
6930    }
6931
6932    #[test]
6933    fn i_brace_works_for_curly_braces() {
6934        let (registry, b, mut doc) = fixture("fn body { return 42; }");
6935        let inv = invoke_textobj(b.delete, b.inner_brace);
6936        // Cursor inside the braces.
6937        execute(
6938            &registry,
6939            &mut doc,
6940            lattice_core::BufferId(0),
6941            Position::new(0, 12),
6942            inv,
6943            &CancellationToken::never(),
6944        )
6945        .unwrap();
6946        assert_eq!(doc.text(), "fn body {}");
6947    }
6948
6949    #[test]
6950    fn unmatched_bracket_is_no_op() {
6951        let (registry, b, mut doc) = fixture("no brackets here");
6952        let inv = invoke_textobj(b.delete, b.inner_paren);
6953        execute(
6954            &registry,
6955            &mut doc,
6956            lattice_core::BufferId(0),
6957            Position::new(0, 5),
6958            inv,
6959            &CancellationToken::never(),
6960        )
6961        .unwrap();
6962        assert_eq!(doc.text(), "no brackets here");
6963    }
6964
6965    #[test]
6966    fn ciw_change_inner_word_enters_insert() {
6967        let (registry, b, mut doc) = fixture("hello world");
6968        let inv = CommandInvocation::of(b.change.0)
6969            .with_target(Target::TextObject(b.inner_word, crate::args::Args::None));
6970        let effect = execute(
6971            &registry,
6972            &mut doc,
6973            lattice_core::BufferId(0),
6974            Position::new(0, 2),
6975            inv,
6976            &CancellationToken::never(),
6977        )
6978        .unwrap();
6979        match effect {
6980            Effect::Many(parts) => {
6981                assert!(matches!(
6982                    parts[parts.len() - 1],
6983                    Effect::EnterMode(crate::ModalState::Insert)
6984                ));
6985            }
6986            other => panic!("expected Many, got {other:?}"),
6987        }
6988        assert_eq!(doc.text(), " world");
6989    }
6990
6991    // ---- Tag text objects (it / at) ----
6992
6993    #[test]
6994    fn it_selects_inside_tag() {
6995        let (registry, b, mut doc) = fixture("<p>hello world</p>");
6996        let inv = CommandInvocation::of(b.delete.0)
6997            .with_target(Target::TextObject(b.inner_tag, crate::args::Args::None));
6998        // Cursor inside <p>: byte 5 ('e' of "hello").
6999        execute(
7000            &registry,
7001            &mut doc,
7002            lattice_core::BufferId(0),
7003            Position::new(0, 5),
7004            inv,
7005            &CancellationToken::never(),
7006        )
7007        .unwrap();
7008        assert_eq!(doc.text(), "<p></p>");
7009    }
7010
7011    #[test]
7012    fn at_selects_around_tag() {
7013        let (registry, b, mut doc) = fixture("<p>hello world</p>");
7014        let inv = CommandInvocation::of(b.delete.0)
7015            .with_target(Target::TextObject(b.around_tag, crate::args::Args::None));
7016        execute(
7017            &registry,
7018            &mut doc,
7019            lattice_core::BufferId(0),
7020            Position::new(0, 5),
7021            inv,
7022            &CancellationToken::never(),
7023        )
7024        .unwrap();
7025        assert_eq!(doc.text(), "");
7026    }
7027
7028    #[test]
7029    fn it_with_no_enclosing_tag_is_no_op() {
7030        let (registry, b, mut doc) = fixture("plain text");
7031        let inv = CommandInvocation::of(b.delete.0)
7032            .with_target(Target::TextObject(b.inner_tag, crate::args::Args::None));
7033        execute(
7034            &registry,
7035            &mut doc,
7036            lattice_core::BufferId(0),
7037            Position::new(0, 4),
7038            inv,
7039            &CancellationToken::never(),
7040        )
7041        .unwrap();
7042        assert_eq!(doc.text(), "plain text");
7043    }
7044
7045    // ---- Sentence motions and text objects ----
7046
7047    #[test]
7048    fn sentence_forward_advances_after_period_space() {
7049        let (registry, b, mut doc) = fixture("First sentence. Second sentence. Third.");
7050        let inv = CommandInvocation::of(b.sentence_forward.0);
7051        let effect = execute(
7052            &registry,
7053            &mut doc,
7054            lattice_core::BufferId(0),
7055            Position::ZERO,
7056            inv,
7057            &CancellationToken::never(),
7058        )
7059        .unwrap();
7060        match effect {
7061            Effect::CursorMove(pos) => {
7062                // After "First sentence. " -> 'S' of "Second" at byte 16.
7063                assert_eq!(pos, Position::new(0, 16));
7064            }
7065            other => panic!("expected CursorMove, got {other:?}"),
7066        }
7067    }
7068
7069    #[test]
7070    fn sentence_backward_returns_to_previous_start() {
7071        let (registry, b, mut doc) = fixture("First. Second.");
7072        // Cursor on 'S' of "Second" at byte 7.
7073        let inv = CommandInvocation::of(b.sentence_backward.0);
7074        let effect = execute(
7075            &registry,
7076            &mut doc,
7077            lattice_core::BufferId(0),
7078            Position::new(0, 7),
7079            inv,
7080            &CancellationToken::never(),
7081        )
7082        .unwrap();
7083        match effect {
7084            Effect::CursorMove(pos) => assert_eq!(pos, Position::ZERO),
7085            other => panic!("expected CursorMove, got {other:?}"),
7086        }
7087    }
7088
7089    #[test]
7090    fn dis_deletes_inner_sentence() {
7091        let (registry, b, mut doc) = fixture("First sentence. Second sentence.");
7092        // Cursor on byte 5 (inside "First sentence").
7093        let inv = CommandInvocation::of(b.delete.0).with_target(Target::TextObject(
7094            b.inner_sentence,
7095            crate::args::Args::None,
7096        ));
7097        execute(
7098            &registry,
7099            &mut doc,
7100            lattice_core::BufferId(0),
7101            Position::new(0, 5),
7102            inv,
7103            &CancellationToken::never(),
7104        )
7105        .unwrap();
7106        // Inner stops before the period; period stays.
7107        assert!(doc.text().starts_with('.'));
7108    }
7109
7110    // ---- Paragraph motions and text objects ----
7111
7112    #[test]
7113    fn paragraph_forward_lands_on_next_blank_line() {
7114        let (registry, b, mut doc) = fixture("foo\nbar\n\nbaz");
7115        let inv = CommandInvocation::of(b.paragraph_forward.0);
7116        let effect = execute(
7117            &registry,
7118            &mut doc,
7119            lattice_core::BufferId(0),
7120            Position::ZERO,
7121            inv,
7122            &CancellationToken::never(),
7123        )
7124        .unwrap();
7125        match effect {
7126            Effect::CursorMove(pos) => {
7127                // First blank line is line 2.
7128                assert_eq!(pos, Position::new(2, 0));
7129            }
7130            other => panic!("expected CursorMove, got {other:?}"),
7131        }
7132    }
7133
7134    #[test]
7135    fn paragraph_backward_lands_on_previous_blank_line() {
7136        let (registry, b, mut doc) = fixture("foo\n\nbar\nbaz");
7137        let inv = CommandInvocation::of(b.paragraph_backward.0);
7138        let effect = execute(
7139            &registry,
7140            &mut doc,
7141            lattice_core::BufferId(0),
7142            Position::new(3, 0),
7143            inv,
7144            &CancellationToken::never(),
7145        )
7146        .unwrap();
7147        match effect {
7148            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(1, 0)),
7149            other => panic!("expected CursorMove, got {other:?}"),
7150        }
7151    }
7152
7153    #[test]
7154    fn paragraph_forward_at_end_of_buffer_clamps() {
7155        let (registry, b, mut doc) = fixture("foo\nbar");
7156        let inv = CommandInvocation::of(b.paragraph_forward.0);
7157        let effect = execute(
7158            &registry,
7159            &mut doc,
7160            lattice_core::BufferId(0),
7161            Position::ZERO,
7162            inv,
7163            &CancellationToken::never(),
7164        )
7165        .unwrap();
7166        match effect {
7167            Effect::CursorMove(pos) => {
7168                // No blank line; lands at last addressable line.
7169                assert_eq!(pos.line, 1);
7170            }
7171            other => panic!("expected CursorMove, got {other:?}"),
7172        }
7173    }
7174
7175    #[test]
7176    fn dap_deletes_paragraph_with_blank_lines() {
7177        let (registry, b, mut doc) = fixture("foo\nbar\n\nbaz");
7178        let inv = CommandInvocation::of(b.delete.0).with_target(Target::TextObject(
7179            b.around_paragraph,
7180            crate::args::Args::None,
7181        ));
7182        execute(
7183            &registry,
7184            &mut doc,
7185            lattice_core::BufferId(0),
7186            Position::ZERO,
7187            inv,
7188            &CancellationToken::never(),
7189        )
7190        .unwrap();
7191        // Around paragraph: first non-blank run + trailing blank line.
7192        assert!(doc.text().contains("baz"));
7193    }
7194
7195    #[test]
7196    fn dip_deletes_paragraph_only() {
7197        let (registry, b, mut doc) = fixture("foo\nbar\n\nbaz");
7198        let inv = CommandInvocation::of(b.delete.0).with_target(Target::TextObject(
7199            b.inner_paragraph,
7200            crate::args::Args::None,
7201        ));
7202        execute(
7203            &registry,
7204            &mut doc,
7205            lattice_core::BufferId(0),
7206            Position::ZERO,
7207            inv,
7208            &CancellationToken::never(),
7209        )
7210        .unwrap();
7211        // Inner paragraph: just the non-blank run, blank line preserved.
7212        assert!(doc.text().starts_with('\n') || doc.text().starts_with("\nbaz"));
7213    }
7214
7215    // ---- WORD motions (W, B, E) ----
7216
7217    #[test]
7218    fn big_word_forward_treats_punctuation_as_part_of_word() {
7219        // word_forward stops at punctuation; big_word_forward doesn't.
7220        let (registry, b, mut doc) = fixture("foo,bar baz");
7221        let inv = CommandInvocation::of(b.big_word_forward.0);
7222        let effect = execute(
7223            &registry,
7224            &mut doc,
7225            lattice_core::BufferId(0),
7226            Position::ZERO,
7227            inv,
7228            &CancellationToken::never(),
7229        )
7230        .unwrap();
7231        match effect {
7232            Effect::CursorMove(pos) => {
7233                // From byte 0, "foo,bar" is one WORD; next WORD is "baz" at byte 8.
7234                assert_eq!(pos, Position::new(0, 8));
7235            }
7236            other => panic!("expected CursorMove, got {other:?}"),
7237        }
7238    }
7239
7240    #[test]
7241    fn big_word_backward_skips_to_word_start() {
7242        let (registry, b, mut doc) = fixture("foo,bar baz");
7243        // From byte 8 ('b' of "baz") `B` -> byte 0 ('f' of "foo,bar").
7244        let inv = CommandInvocation::of(b.big_word_backward.0);
7245        let effect = execute(
7246            &registry,
7247            &mut doc,
7248            lattice_core::BufferId(0),
7249            Position::new(0, 8),
7250            inv,
7251            &CancellationToken::never(),
7252        )
7253        .unwrap();
7254        match effect {
7255            Effect::CursorMove(pos) => assert_eq!(pos, Position::ZERO),
7256            other => panic!("expected CursorMove, got {other:?}"),
7257        }
7258    }
7259
7260    #[test]
7261    fn big_word_end_lands_at_last_byte_of_big_word() {
7262        let (registry, b, mut doc) = fixture("foo,bar baz");
7263        let inv = CommandInvocation::of(b.big_word_end.0);
7264        let effect = execute(
7265            &registry,
7266            &mut doc,
7267            lattice_core::BufferId(0),
7268            Position::ZERO,
7269            inv,
7270            &CancellationToken::never(),
7271        )
7272        .unwrap();
7273        match effect {
7274            Effect::CursorMove(pos) => {
7275                // End of "foo,bar" is byte 6 (the 'r').
7276                assert_eq!(pos, Position::new(0, 6));
7277            }
7278            other => panic!("expected CursorMove, got {other:?}"),
7279        }
7280    }
7281
7282    // ---- find-char / till-char (f, F, t, T) ----
7283
7284    fn invoke_with_char(id: crate::registry::MotionId, c: char) -> CommandInvocation {
7285        CommandInvocation::of(id.0).with_args(crate::args::Args::Char(c))
7286    }
7287
7288    #[test]
7289    fn find_char_forward_lands_on_next_occurrence_on_line() {
7290        let (registry, b, mut doc) = fixture("hello world");
7291        // From byte 0 ('h') `fo` -> 'o' of "hello" at byte 4.
7292        let inv = invoke_with_char(b.find_char_forward, 'o');
7293        let effect = execute(
7294            &registry,
7295            &mut doc,
7296            lattice_core::BufferId(0),
7297            Position::ZERO,
7298            inv,
7299            &CancellationToken::never(),
7300        )
7301        .unwrap();
7302        match effect {
7303            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 4)),
7304            other => panic!("expected CursorMove, got {other:?}"),
7305        }
7306    }
7307
7308    #[test]
7309    fn find_char_forward_skips_current_byte_so_it_advances() {
7310        let (registry, b, mut doc) = fixture("oxox");
7311        // From 'o' at byte 0, `fo` -> next 'o' at byte 2.
7312        let inv = invoke_with_char(b.find_char_forward, 'o');
7313        let effect = execute(
7314            &registry,
7315            &mut doc,
7316            lattice_core::BufferId(0),
7317            Position::ZERO,
7318            inv,
7319            &CancellationToken::never(),
7320        )
7321        .unwrap();
7322        match effect {
7323            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 2)),
7324            other => panic!("expected CursorMove, got {other:?}"),
7325        }
7326    }
7327
7328    #[test]
7329    fn find_char_forward_no_match_is_no_op() {
7330        let (registry, b, mut doc) = fixture("hello");
7331        let inv = invoke_with_char(b.find_char_forward, 'z');
7332        let effect = execute(
7333            &registry,
7334            &mut doc,
7335            lattice_core::BufferId(0),
7336            Position::ZERO,
7337            inv,
7338            &CancellationToken::never(),
7339        )
7340        .unwrap();
7341        match effect {
7342            Effect::CursorMove(pos) => assert_eq!(pos, Position::ZERO),
7343            other => panic!("expected CursorMove, got {other:?}"),
7344        }
7345    }
7346
7347    #[test]
7348    fn find_char_forward_does_not_cross_newlines() {
7349        let (registry, b, mut doc) = fixture("hello\nworld");
7350        // From byte 0, `fw` should NOT find 'w' on line 1.
7351        let inv = invoke_with_char(b.find_char_forward, 'w');
7352        let effect = execute(
7353            &registry,
7354            &mut doc,
7355            lattice_core::BufferId(0),
7356            Position::ZERO,
7357            inv,
7358            &CancellationToken::never(),
7359        )
7360        .unwrap();
7361        match effect {
7362            Effect::CursorMove(pos) => assert_eq!(pos, Position::ZERO),
7363            other => panic!("expected CursorMove, got {other:?}"),
7364        }
7365    }
7366
7367    #[test]
7368    fn find_char_backward_lands_on_previous_occurrence() {
7369        let (registry, b, mut doc) = fixture("hello world");
7370        // From byte 8 ('r' of "world") `Fo` -> 'o' of "world" at byte 7.
7371        let inv = invoke_with_char(b.find_char_backward, 'o');
7372        let effect = execute(
7373            &registry,
7374            &mut doc,
7375            lattice_core::BufferId(0),
7376            Position::new(0, 8),
7377            inv,
7378            &CancellationToken::never(),
7379        )
7380        .unwrap();
7381        match effect {
7382            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 7)),
7383            other => panic!("expected CursorMove, got {other:?}"),
7384        }
7385    }
7386
7387    #[test]
7388    fn find_char_backward_no_match_is_no_op() {
7389        let (registry, b, mut doc) = fixture("hello");
7390        let inv = invoke_with_char(b.find_char_backward, 'z');
7391        let effect = execute(
7392            &registry,
7393            &mut doc,
7394            lattice_core::BufferId(0),
7395            Position::new(0, 4),
7396            inv,
7397            &CancellationToken::never(),
7398        )
7399        .unwrap();
7400        match effect {
7401            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 4)),
7402            other => panic!("expected CursorMove, got {other:?}"),
7403        }
7404    }
7405
7406    #[test]
7407    fn till_char_forward_lands_one_byte_before_match() {
7408        let (registry, b, mut doc) = fixture("hello world");
7409        // `tw` from byte 0 -> byte 5 (space, one before 'w' at byte 6).
7410        let inv = invoke_with_char(b.till_char_forward, 'w');
7411        let effect = execute(
7412            &registry,
7413            &mut doc,
7414            lattice_core::BufferId(0),
7415            Position::ZERO,
7416            inv,
7417            &CancellationToken::never(),
7418        )
7419        .unwrap();
7420        match effect {
7421            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 5)),
7422            other => panic!("expected CursorMove, got {other:?}"),
7423        }
7424    }
7425
7426    #[test]
7427    fn till_char_backward_lands_one_byte_after_match() {
7428        let (registry, b, mut doc) = fixture("hello world");
7429        // `Th` from byte 8 -> byte 1 (one after 'h' at byte 0).
7430        let inv = invoke_with_char(b.till_char_backward, 'h');
7431        let effect = execute(
7432            &registry,
7433            &mut doc,
7434            lattice_core::BufferId(0),
7435            Position::new(0, 8),
7436            inv,
7437            &CancellationToken::never(),
7438        )
7439        .unwrap();
7440        match effect {
7441            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 1)),
7442            other => panic!("expected CursorMove, got {other:?}"),
7443        }
7444    }
7445
7446    #[test]
7447    fn find_char_with_no_args_errors() {
7448        let (registry, b, mut doc) = fixture("hello");
7449        // No Args::Char supplied -> InvalidArgs.
7450        let inv = CommandInvocation::of(b.find_char_forward.0);
7451        let err = execute(
7452            &registry,
7453            &mut doc,
7454            lattice_core::BufferId(0),
7455            Position::ZERO,
7456            inv,
7457            &CancellationToken::never(),
7458        )
7459        .unwrap_err();
7460        assert!(matches!(err, CommandError::InvalidArgs(_)));
7461    }
7462
7463    #[test]
7464    fn find_char_forward_handles_unicode_target() {
7465        let (registry, b, mut doc) = fixture("café au lait");
7466        // `fé` from byte 0 -> byte 3 ('é' starts at byte 3 in "café").
7467        let inv = invoke_with_char(b.find_char_forward, 'é');
7468        let effect = execute(
7469            &registry,
7470            &mut doc,
7471            lattice_core::BufferId(0),
7472            Position::ZERO,
7473            inv,
7474            &CancellationToken::never(),
7475        )
7476        .unwrap();
7477        match effect {
7478            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 3)),
7479            other => panic!("expected CursorMove, got {other:?}"),
7480        }
7481    }
7482
7483    // ---- change operator (c) ----
7484
7485    #[test]
7486    fn change_with_word_forward_emits_edits_yank_and_enter_insert() {
7487        let (registry, b, mut doc) = fixture("hello world");
7488        let inv = CommandInvocation::of(b.change.0)
7489            .with_target(Target::Motion(b.word_forward, crate::args::Args::None));
7490        let effect = execute(
7491            &registry,
7492            &mut doc,
7493            lattice_core::BufferId(0),
7494            Position::ZERO,
7495            inv,
7496            &CancellationToken::never(),
7497        )
7498        .unwrap();
7499        // Effect::Many([Edits, Yank, EnterMode(Insert)]).
7500        match effect {
7501            Effect::Many(parts) => {
7502                assert_eq!(parts.len(), 3);
7503                assert!(matches!(parts[0], Effect::Edits(_)));
7504                assert!(matches!(parts[1], Effect::Yank { .. }));
7505                assert!(matches!(
7506                    parts[2],
7507                    Effect::EnterMode(crate::ModalState::Insert)
7508                ));
7509            }
7510            other => panic!("expected Effect::Many, got {other:?}"),
7511        }
7512        assert_eq!(doc.text(), "world");
7513    }
7514
7515    #[test]
7516    fn change_current_line_clears_line_and_enters_insert() {
7517        let (registry, b, mut doc) = fixture("aaa\nBBB\nccc");
7518        let cursor = Position::new(1, 0);
7519        let inv = CommandInvocation::of(b.change.0).with_range(crate::range::Range::CurrentLine);
7520        let effect = execute(
7521            &registry,
7522            &mut doc,
7523            lattice_core::BufferId(0),
7524            cursor,
7525            inv,
7526            &CancellationToken::never(),
7527        )
7528        .unwrap();
7529        match effect {
7530            Effect::Many(parts) => {
7531                assert_eq!(parts.len(), 3);
7532                // CurrentLine -> linewise yank.
7533                match &parts[1] {
7534                    Effect::Yank { kind, .. } => assert_eq!(*kind, YankKind::Linewise),
7535                    other => panic!("expected Yank at [1], got {other:?}"),
7536                }
7537                assert!(matches!(
7538                    parts[2],
7539                    Effect::EnterMode(crate::ModalState::Insert)
7540                ));
7541            }
7542            other => panic!("expected Effect::Many, got {other:?}"),
7543        }
7544        assert_eq!(doc.text(), "aaa\n\nccc");
7545    }
7546
7547    #[test]
7548    fn change_with_line_end_target_truncates_line_and_enters_insert() {
7549        // `c$` from byte 5 of "hello world" leaves "hello" and enters Insert.
7550        let (registry, b, mut doc) = fixture("hello world");
7551        let inv = CommandInvocation::of(b.change.0)
7552            .with_target(Target::Motion(b.line_end, crate::args::Args::None));
7553        let effect = execute(
7554            &registry,
7555            &mut doc,
7556            lattice_core::BufferId(0),
7557            Position::new(0, 5),
7558            inv,
7559            &CancellationToken::never(),
7560        )
7561        .unwrap();
7562        match effect {
7563            Effect::Many(parts) => {
7564                assert_eq!(parts.len(), 3);
7565                assert!(matches!(
7566                    parts[2],
7567                    Effect::EnterMode(crate::ModalState::Insert)
7568                ));
7569            }
7570            other => panic!("expected Effect::Many, got {other:?}"),
7571        }
7572        assert_eq!(doc.text(), "hello");
7573    }
7574
7575    // ---- replace-char operator (r{char} / Visual r) ----
7576
7577    /// Helper: build a single visual selection on `doc`.
7578    fn set_visual(
7579        doc: &mut Document,
7580        anchor: Position,
7581        head: Position,
7582        visual: lattice_protocol::selection::VisualMode,
7583    ) {
7584        use lattice_protocol::selection::{Selection, SelectionSet};
7585        doc.set_selections(SelectionSet::single(Selection {
7586            anchor,
7587            head,
7588            visual: Some(visual),
7589        }));
7590    }
7591
7592    #[test]
7593    fn replace_char_overwrites_single_char_under_cursor() {
7594        let (registry, b, mut doc) = fixture("hello");
7595        let inv = CommandInvocation::of(b.replace_char.0)
7596            .with_target(Target::Motion(b.char_right, crate::args::Args::None))
7597            .with_args(crate::args::Args::Char('a'));
7598        let eff = execute(
7599            &registry,
7600            &mut doc,
7601            lattice_core::BufferId(0),
7602            Position::ZERO,
7603            inv,
7604            &CancellationToken::never(),
7605        )
7606        .unwrap();
7607        assert!(matches!(eff, Effect::Edits(_)));
7608        assert_eq!(doc.text(), "aello");
7609    }
7610
7611    #[test]
7612    fn replace_char_with_count_overwrites_count_chars() {
7613        let (registry, b, mut doc) = fixture("hello");
7614        let inv = CommandInvocation::of(b.replace_char.0)
7615            .with_target(Target::Motion(b.char_right, crate::args::Args::None))
7616            .with_args(crate::args::Args::Char('a'))
7617            .with_count(Count(3));
7618        execute(
7619            &registry,
7620            &mut doc,
7621            lattice_core::BufferId(0),
7622            Position::ZERO,
7623            inv,
7624            &CancellationToken::never(),
7625        )
7626        .unwrap();
7627        assert_eq!(doc.text(), "aaalo");
7628    }
7629
7630    #[test]
7631    fn replace_char_count_too_large_is_a_no_op() {
7632        // Vim's `3r` on a 2-char line replaces nothing (bells).
7633        let (registry, b, mut doc) = fixture("ab");
7634        let inv = CommandInvocation::of(b.replace_char.0)
7635            .with_target(Target::Motion(b.char_right, crate::args::Args::None))
7636            .with_args(crate::args::Args::Char('x'))
7637            .with_count(Count(3));
7638        let eff = execute(
7639            &registry,
7640            &mut doc,
7641            lattice_core::BufferId(0),
7642            Position::ZERO,
7643            inv,
7644            &CancellationToken::never(),
7645        )
7646        .unwrap();
7647        assert!(matches!(eff, Effect::None));
7648        assert_eq!(doc.text(), "ab");
7649    }
7650
7651    #[test]
7652    fn replace_char_on_empty_line_is_a_no_op() {
7653        let (registry, b, mut doc) = fixture("");
7654        let inv = CommandInvocation::of(b.replace_char.0)
7655            .with_target(Target::Motion(b.char_right, crate::args::Args::None))
7656            .with_args(crate::args::Args::Char('x'));
7657        let eff = execute(
7658            &registry,
7659            &mut doc,
7660            lattice_core::BufferId(0),
7661            Position::ZERO,
7662            inv,
7663            &CancellationToken::never(),
7664        )
7665        .unwrap();
7666        assert!(matches!(eff, Effect::None));
7667        assert_eq!(doc.text(), "");
7668    }
7669
7670    #[test]
7671    fn visual_replace_char_overwrites_selection() {
7672        use lattice_protocol::selection::VisualMode;
7673        let (registry, b, mut doc) = fixture("hello world");
7674        // Charwise select "hello": anchor byte 0, head byte 4
7675        // (inclusive-head extends to [0, 5)).
7676        set_visual(
7677            &mut doc,
7678            Position::new(0, 0),
7679            Position::new(0, 4),
7680            VisualMode::Charwise,
7681        );
7682        let inv = CommandInvocation::of(b.replace_char.0)
7683            .with_range(crate::range::Range::Selection)
7684            .with_args(crate::args::Args::Char('x'));
7685        execute(
7686            &registry,
7687            &mut doc,
7688            lattice_core::BufferId(0),
7689            Position::new(0, 0),
7690            inv,
7691            &CancellationToken::never(),
7692        )
7693        .unwrap();
7694        assert_eq!(doc.text(), "xxxxx world");
7695    }
7696
7697    #[test]
7698    fn visual_replace_char_preserves_newlines_across_lines() {
7699        use lattice_protocol::selection::VisualMode;
7700        let (registry, b, mut doc) = fixture("ab\ncd");
7701        // Charwise from (0,1) through (1,0): covers "b\nc" (inclusive
7702        // head extends the end to (1,1)).
7703        set_visual(
7704            &mut doc,
7705            Position::new(0, 1),
7706            Position::new(1, 0),
7707            VisualMode::Charwise,
7708        );
7709        let inv = CommandInvocation::of(b.replace_char.0)
7710            .with_range(crate::range::Range::Selection)
7711            .with_args(crate::args::Args::Char('X'));
7712        execute(
7713            &registry,
7714            &mut doc,
7715            lattice_core::BufferId(0),
7716            Position::new(0, 1),
7717            inv,
7718            &CancellationToken::never(),
7719        )
7720        .unwrap();
7721        // Line structure preserved; only the two chars overwritten.
7722        assert_eq!(doc.text(), "aX\nXd");
7723    }
7724
7725    #[test]
7726    fn visual_linewise_replace_char_overwrites_whole_lines() {
7727        use lattice_protocol::selection::VisualMode;
7728        let (registry, b, mut doc) = fixture("aaa\nbbb\nccc");
7729        set_visual(
7730            &mut doc,
7731            Position::new(0, 0),
7732            Position::new(1, 0),
7733            VisualMode::Linewise,
7734        );
7735        let inv = CommandInvocation::of(b.replace_char.0)
7736            .with_range(crate::range::Range::Selection)
7737            .with_args(crate::args::Args::Char('z'));
7738        execute(
7739            &registry,
7740            &mut doc,
7741            lattice_core::BufferId(0),
7742            Position::new(0, 0),
7743            inv,
7744            &CancellationToken::never(),
7745        )
7746        .unwrap();
7747        assert_eq!(doc.text(), "zzz\nzzz\nccc");
7748    }
7749
7750    #[test]
7751    fn visual_blockwise_replace_char_overwrites_each_row_slice() {
7752        use lattice_protocol::selection::VisualMode;
7753        let (registry, b, mut doc) = fixture("abcd\nefgh\nijkl");
7754        // Block from (0,1) to (2,2): columns 1..=2 on each row.
7755        set_visual(
7756            &mut doc,
7757            Position::new(0, 1),
7758            Position::new(2, 2),
7759            VisualMode::Blockwise,
7760        );
7761        let inv = CommandInvocation::of(b.replace_char.0)
7762            .with_range(crate::range::Range::Selection)
7763            .with_args(crate::args::Args::Char('*'));
7764        execute(
7765            &registry,
7766            &mut doc,
7767            lattice_core::BufferId(0),
7768            Position::new(0, 1),
7769            inv,
7770            &CancellationToken::never(),
7771        )
7772        .unwrap();
7773        assert_eq!(doc.text(), "a**d\ne**h\ni**l");
7774    }
7775
7776    #[test]
7777    fn replace_char_without_char_arg_errors() {
7778        let (registry, b, mut doc) = fixture("hello");
7779        let inv = CommandInvocation::of(b.replace_char.0)
7780            .with_target(Target::Motion(b.char_right, crate::args::Args::None));
7781        let err = execute(
7782            &registry,
7783            &mut doc,
7784            lattice_core::BufferId(0),
7785            Position::ZERO,
7786            inv,
7787            &CancellationToken::never(),
7788        )
7789        .expect_err("replace-char requires Args::Char");
7790        assert!(matches!(err, CommandError::InvalidArgs(_)));
7791    }
7792
7793    // ---- yank operator (y) ----
7794
7795    #[test]
7796    fn yank_with_word_forward_emits_charwise_yank_and_zero_register() {
7797        let (registry, b, mut doc) = fixture("hello world");
7798        let original_text = doc.text();
7799        let inv = CommandInvocation::of(b.yank.0)
7800            .with_target(Target::Motion(b.word_forward, crate::args::Args::None));
7801        let effect = execute(
7802            &registry,
7803            &mut doc,
7804            lattice_core::BufferId(0),
7805            Position::ZERO,
7806            inv,
7807            &CancellationToken::never(),
7808        )
7809        .unwrap();
7810        // Yank does NOT touch the buffer.
7811        assert_eq!(doc.text(), original_text);
7812        // Yank emits Many([requested-register, "0, cursor-to-origin]).
7813        match effect {
7814            Effect::Many(parts) => {
7815                assert_eq!(parts.len(), 3);
7816                match &parts[0] {
7817                    Effect::Yank {
7818                        content,
7819                        kind,
7820                        register,
7821                        explicit_yank,
7822                    } => {
7823                        assert_eq!(content, "hello ");
7824                        assert_eq!(*kind, YankKind::Charwise);
7825                        assert_eq!(*register, crate::register::Register::Unnamed);
7826                        assert!(
7827                            *explicit_yank,
7828                            "the primary yank write is clipboard-eligible"
7829                        );
7830                    }
7831                    other => panic!("expected Yank at [0], got {other:?}"),
7832                }
7833                match &parts[1] {
7834                    Effect::Yank { register, .. } => {
7835                        assert_eq!(*register, crate::register::Register::Numbered(0));
7836                    }
7837                    other => panic!("expected Yank(\"0) at [1], got {other:?}"),
7838                }
7839                // VM.3m: the cursor goes to the start of the yanked text, which
7840                // for a forward motion from 0,0 is where it already is.
7841                assert!(matches!(parts[2], Effect::CursorMove(p) if p == Position::ZERO));
7842            }
7843            other => panic!("expected Many, got {other:?}"),
7844        }
7845    }
7846
7847    #[test]
7848    fn yank_with_current_line_range_emits_linewise_yank() {
7849        let (registry, b, mut doc) = fixture("aaa\nBBB\nccc");
7850        let inv = CommandInvocation::of(b.yank.0).with_range(crate::range::Range::CurrentLine);
7851        let effect = execute(
7852            &registry,
7853            &mut doc,
7854            lattice_core::BufferId(0),
7855            Position::new(1, 0),
7856            inv,
7857            &CancellationToken::never(),
7858        )
7859        .unwrap();
7860        assert_eq!(doc.text(), "aaa\nBBB\nccc");
7861        match effect {
7862            Effect::Many(parts) => match &parts[0] {
7863                Effect::Yank { content, kind, .. } => {
7864                    // Slice 8.i.4.g: linewise yank content always
7865                    // ends with `\n` so paste can splice cleanly.
7866                    assert_eq!(content, "BBB\n");
7867                    assert_eq!(*kind, YankKind::Linewise);
7868                }
7869                other => panic!("expected Yank, got {other:?}"),
7870            },
7871            other => panic!("expected Many, got {other:?}"),
7872        }
7873    }
7874
7875    #[test]
7876    fn yank_with_whole_range_emits_linewise_full_buffer() {
7877        let (registry, b, mut doc) = fixture("hello\nworld");
7878        let inv = CommandInvocation::of(b.yank.0).with_range(crate::range::Range::Whole);
7879        let effect = execute(
7880            &registry,
7881            &mut doc,
7882            lattice_core::BufferId(0),
7883            Position::ZERO,
7884            inv,
7885            &CancellationToken::never(),
7886        )
7887        .unwrap();
7888        match effect {
7889            Effect::Many(parts) => match &parts[0] {
7890                Effect::Yank { content, kind, .. } => {
7891                    // Slice 8.i.4.g: linewise yank content always
7892                    // ends with `\n` so paste can splice cleanly,
7893                    // even when the source had no trailing
7894                    // newline (here: `:y` of "hello\nworld").
7895                    assert_eq!(content, "hello\nworld\n");
7896                    assert_eq!(*kind, YankKind::Linewise);
7897                }
7898                other => panic!("expected Yank, got {other:?}"),
7899            },
7900            other => panic!("expected Many, got {other:?}"),
7901        }
7902    }
7903
7904    #[test]
7905    fn yank_does_not_modify_buffer() {
7906        let (registry, b, mut doc) = fixture("immutable text");
7907        let original = doc.text();
7908        let inv = CommandInvocation::of(b.yank.0).with_range(crate::range::Range::Whole);
7909        execute(
7910            &registry,
7911            &mut doc,
7912            lattice_core::BufferId(0),
7913            Position::ZERO,
7914            inv,
7915            &CancellationToken::never(),
7916        )
7917        .unwrap();
7918        assert_eq!(doc.text(), original);
7919    }
7920
7921    #[test]
7922    fn yank_empty_range_returns_none() {
7923        let (registry, b, mut doc) = fixture("");
7924        let inv = CommandInvocation::of(b.yank.0).with_range(crate::range::Range::Whole);
7925        let effect = execute(
7926            &registry,
7927            &mut doc,
7928            lattice_core::BufferId(0),
7929            Position::ZERO,
7930            inv,
7931            &CancellationToken::never(),
7932        )
7933        .unwrap();
7934        // Empty buffer / empty range -> Effect::None.
7935        assert!(matches!(effect, Effect::None));
7936    }
7937
7938    // ---- delete-yanks-into-register (composite verification) ----
7939
7940    #[test]
7941    fn delete_charwise_yanks_charwise() {
7942        let (registry, b, mut doc) = fixture("hello world");
7943        let inv = CommandInvocation::of(b.delete.0)
7944            .with_target(Target::Motion(b.word_forward, crate::args::Args::None));
7945        let effect = execute(
7946            &registry,
7947            &mut doc,
7948            lattice_core::BufferId(0),
7949            Position::ZERO,
7950            inv,
7951            &CancellationToken::never(),
7952        )
7953        .unwrap();
7954        match effect {
7955            Effect::Many(parts) => match &parts[1] {
7956                Effect::Yank { kind, content, .. } => {
7957                    assert_eq!(*kind, YankKind::Charwise);
7958                    assert_eq!(content, "hello ");
7959                }
7960                other => panic!("expected Yank, got {other:?}"),
7961            },
7962            other => panic!("expected Many, got {other:?}"),
7963        }
7964    }
7965
7966    #[test]
7967    fn delete_linewise_yanks_linewise() {
7968        let (registry, b, mut doc) = fixture("aaa\nBBB\nccc");
7969        let inv = CommandInvocation::of(b.delete.0).with_range(crate::range::Range::CurrentLine);
7970        let effect = execute(
7971            &registry,
7972            &mut doc,
7973            lattice_core::BufferId(0),
7974            Position::new(1, 0),
7975            inv,
7976            &CancellationToken::never(),
7977        )
7978        .unwrap();
7979        match effect {
7980            Effect::Many(parts) => match &parts[1] {
7981                Effect::Yank { kind, .. } => assert_eq!(*kind, YankKind::Linewise),
7982                other => panic!("expected Yank, got {other:?}"),
7983            },
7984            other => panic!("expected Many, got {other:?}"),
7985        }
7986    }
7987
7988    #[test]
7989    fn delete_emits_many_with_edits_and_yank_only() {
7990        // Sanity: delete emits Many([Edits, Yank]) -- never EnterMode.
7991        let (registry, b, mut doc) = fixture("hello world");
7992        let inv = CommandInvocation::of(b.delete.0)
7993            .with_target(Target::Motion(b.word_forward, crate::args::Args::None));
7994        let effect = execute(
7995            &registry,
7996            &mut doc,
7997            lattice_core::BufferId(0),
7998            Position::ZERO,
7999            inv,
8000            &CancellationToken::never(),
8001        )
8002        .unwrap();
8003        match effect {
8004            Effect::Many(parts) => {
8005                assert_eq!(parts.len(), 2);
8006                assert!(!parts.iter().any(|e| matches!(e, Effect::EnterMode(_))));
8007            }
8008            other => panic!("expected Many, got {other:?}"),
8009        }
8010    }
8011
8012    #[test]
8013    fn delete_with_word_end_target_de_semantics() {
8014        // `de` from start of "hello world": word_end (`e`) is INCLUSIVE, so
8015        // it lands on the last char of "hello" ('o', byte 4) and the operator
8016        // covers that char too -- range [0, 5) -> deletes "hello", leaving
8017        // " world". Vim parity; previously the inclusive flag was ignored and
8018        // this left the trailing 'o' ("o world").
8019        let (registry, b, mut doc) = fixture("hello world");
8020        let inv = CommandInvocation::of(b.delete.0)
8021            .with_target(Target::Motion(b.word_end, crate::args::Args::None));
8022        execute(
8023            &registry,
8024            &mut doc,
8025            lattice_core::BufferId(0),
8026            Position::ZERO,
8027            inv,
8028            &CancellationToken::never(),
8029        )
8030        .unwrap();
8031        assert_eq!(doc.text(), " world");
8032    }
8033
8034    #[test]
8035    fn change_with_word_end_target_ce_is_inclusive() {
8036        // `ce` deletes through the end of the word (inclusive `e`), same as
8037        // `de` but leaving the operator in change-mode. From byte 0 of
8038        // "hello world" it removes "hello" -> " world".
8039        let (registry, b, mut doc) = fixture("hello world");
8040        let inv = CommandInvocation::of(b.change.0)
8041            .with_target(Target::Motion(b.word_end, crate::args::Args::None));
8042        execute(
8043            &registry,
8044            &mut doc,
8045            lattice_core::BufferId(0),
8046            Position::ZERO,
8047            inv,
8048            &CancellationToken::never(),
8049        )
8050        .unwrap();
8051        assert_eq!(doc.text(), " world");
8052    }
8053
8054    #[test]
8055    fn delete_with_find_char_target_df_is_inclusive() {
8056        // `dfl` deletes up to AND including the found char (inclusive `f`).
8057        // From byte 0 of "hello", `f` for 'l' lands on byte 2; deletion
8058        // covers [0, 3) -> "lo".
8059        let (registry, b, mut doc) = fixture("hello");
8060        let inv = CommandInvocation::of(b.delete.0).with_target(Target::Motion(
8061            b.find_char_forward,
8062            crate::args::Args::Char('l'),
8063        ));
8064        execute(
8065            &registry,
8066            &mut doc,
8067            lattice_core::BufferId(0),
8068            Position::ZERO,
8069            inv,
8070            &CancellationToken::never(),
8071        )
8072        .unwrap();
8073        assert_eq!(doc.text(), "lo");
8074    }
8075
8076    #[test]
8077    fn dw_on_last_word_of_line_stops_at_line_end() {
8078        // Vim word-motion special case: `dw` on the last word of a line
8079        // deletes to the line end and does NOT join the next line. Cursor on
8080        // 'f' of "foo" (byte 6 of line 0); `w` would cross to "bar" on line 1,
8081        // but under the operator the range is clamped to the end of line 0.
8082        let (registry, b, mut doc) = fixture("hello foo\nbar");
8083        let inv = CommandInvocation::of(b.delete.0)
8084            .with_target(Target::Motion(b.word_forward, crate::args::Args::None));
8085        execute(
8086            &registry,
8087            &mut doc,
8088            lattice_core::BufferId(0),
8089            Position::new(0, 6),
8090            inv,
8091            &CancellationToken::never(),
8092        )
8093        .unwrap();
8094        assert_eq!(doc.text(), "hello \nbar", "dw must not cross the newline");
8095    }
8096
8097    #[test]
8098    fn delete_with_motion_target_for_motion_id_kind_mismatch_is_caught() {
8099        // Constructing a Target::Motion from a *non-motion* command id should
8100        // surface a kind mismatch, not silently succeed.
8101        let (registry, b, mut doc) = fixture("abc");
8102        // Use `delete` (an operator) as if it were a motion. Should fail.
8103        let bogus = MotionId(b.delete.0);
8104        let inv = CommandInvocation::of(b.delete.0)
8105            .with_target(Target::Motion(bogus, crate::args::Args::None));
8106        let err = execute(
8107            &registry,
8108            &mut doc,
8109            lattice_core::BufferId(0),
8110            Position::ZERO,
8111            inv,
8112            &CancellationToken::never(),
8113        )
8114        .unwrap_err();
8115        assert!(matches!(err, CommandError::KindMismatch { .. }));
8116    }
8117}