Skip to main content

hjkl_vim/
pending.rs

1/// Pending-state machine for second-key chords. The umbrella stores
2/// `Option<PendingState>`; when `Some`, it routes keys through `step`
3/// instead of the keymap trie.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum PendingState {
6    Replace {
7        count: usize,
8    },
9    /// `f<x>` / `F<x>` / `t<x>` / `T<x>` — find single char on current line.
10    /// `forward` = direction (true for f/t, false for F/T).
11    /// `till` = stop one char before target (true for t/T, false for f/F).
12    Find {
13        count: usize,
14        forward: bool,
15        till: bool,
16    },
17    /// `g<x>` — bare g-prefix chord in Normal / Visual mode. The app sets this
18    /// after intercepting `g`; `step` routes the next `Key::Char(ch)` to
19    /// `EngineCmd::AfterGChord { ch, count }`. `Key::Esc` cancels; any
20    /// non-char key also cancels (mirrors the `Find` arm).
21    AfterG {
22        count: usize,
23    },
24    /// `z<x>` — bare z-prefix chord in Normal / Visual mode. The app sets this
25    /// after intercepting `z`; `step` routes the next `Key::Char(ch)` to
26    /// `EngineCmd::AfterZChord { ch, count }`. `Key::Esc` cancels; any
27    /// non-char key also cancels (mirrors the `AfterG` arm).
28    AfterZ {
29        count: usize,
30    },
31    /// `d<x>` / `y<x>` / `c<x>` / `><x>` / `<<x>` — bare op-pending entered
32    /// from Normal mode after the operator key. `count1` is the count pressed
33    /// before the operator; `inner_count` accumulates digits pressed after the
34    /// operator (e.g. `d3w` → count1=1, inner_count=3, total=3). The reducer
35    /// is authoritative for both counts; `total = count1.max(1) *
36    /// inner_count.max(1)` is passed to the engine on completion.
37    ///
38    /// Vim quirk: a bare `0` when `inner_count == 0` is the line-start motion
39    /// (`LineStart`), not a digit. Any other digit, or `0` when `inner_count >
40    /// 0`, accumulates.
41    AfterOp {
42        op: crate::operator::OperatorKind,
43        count1: usize,
44        inner_count: usize,
45    },
46    /// `df<x>` / `dF<x>` / `dt<x>` / `dT<x>` and same for y/c/>/<. Reached
47    /// from `AfterOp` when the next key after the operator is `f`/`F`/`t`/`T`.
48    /// `total_count = count1.max(1) * inner_count.max(1)` already folded at
49    /// transition time; neither component is independently meaningful after
50    /// this point.
51    ///
52    /// The next char is the find target. `Key::Esc` or any non-char cancels
53    /// (vim's `f<Esc>` cancel semantics apply here too).
54    ///
55    /// `cf<x>` stays as Change + Find — the cw→ce quirk in `apply_op_with_motion`
56    /// only rewrites `Motion::WordFwd`/`BigWordFwd`, not `Motion::Find`.
57    OpFind {
58        op: crate::operator::OperatorKind,
59        total_count: usize,
60        forward: bool,
61        till: bool,
62    },
63    // 2c–2e sub-state variants continue to land in later chunks.
64}
65
66/// One step of the reducer.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum Outcome {
69    /// Need more keys — keep accumulating with new state.
70    Wait(PendingState),
71    /// Run this engine command, then clear pending.
72    Commit(crate::cmd::EngineCmd),
73    /// Cancel pending (Esc, invalid char, etc.). No engine call.
74    Cancel,
75    /// Pending state didn't consume this key — host should route it
76    /// normally (e.g. modifier-only key). Pending state stays alive.
77    Forward,
78}
79
80/// `Key` is intentionally minimal — hjkl-vim should not depend on
81/// crossterm. Hosts translate their native keys into this shape.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum Key {
84    Char(char),
85    Esc,
86    Enter,
87    Backspace,
88    Tab,
89    // Add more variants only as later chunks require them.
90}
91
92pub fn step(state: PendingState, key: Key) -> Outcome {
93    match state {
94        PendingState::Replace { count } => match key {
95            Key::Esc => Outcome::Cancel,
96            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::ReplaceChar { ch, count }),
97            Key::Enter => Outcome::Commit(crate::cmd::EngineCmd::ReplaceChar { ch: '\n', count }),
98            _ => Outcome::Cancel,
99        },
100        PendingState::Find {
101            count,
102            forward,
103            till,
104        } => match key {
105            Key::Esc => Outcome::Cancel,
106            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::FindChar {
107                ch,
108                forward,
109                till,
110                count,
111            }),
112            // Any non-char key cancels (vim cancels f<non-char>).
113            _ => Outcome::Cancel,
114        },
115        PendingState::AfterG { count } => match key {
116            Key::Esc => Outcome::Cancel,
117            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::AfterGChord { ch, count }),
118            // Any non-char key cancels (mirrors Find arm).
119            _ => Outcome::Cancel,
120        },
121        PendingState::AfterZ { count } => match key {
122            Key::Esc => Outcome::Cancel,
123            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::AfterZChord { ch, count }),
124            // Any non-char key cancels (mirrors AfterG arm).
125            _ => Outcome::Cancel,
126        },
127        PendingState::AfterOp {
128            op,
129            count1,
130            inner_count,
131        } => match key {
132            Key::Esc => Outcome::Cancel,
133            Key::Char(d @ '0'..='9') => {
134                // Vim quirk: bare `0` with inner_count==0 is LineStart motion.
135                if d == '0' && inner_count == 0 {
136                    // Treat as motion key — engine will parse '0' as LineStart.
137                    let total = count1.max(1);
138                    Outcome::Commit(crate::cmd::EngineCmd::ApplyOpMotion {
139                        op,
140                        motion_key: '0',
141                        total_count: total,
142                    })
143                } else {
144                    let new_inner = inner_count
145                        .saturating_mul(10)
146                        .saturating_add(d as usize - '0' as usize);
147                    Outcome::Wait(PendingState::AfterOp {
148                        op,
149                        count1,
150                        inner_count: new_inner,
151                    })
152                }
153            }
154            Key::Char(ch) => {
155                let total = count1.max(1) * inner_count.max(1);
156                // Doubled letter → line op (dd/yy/cc/>>/<<).
157                if ch == op.double_char() {
158                    Outcome::Commit(crate::cmd::EngineCmd::ApplyOpDouble {
159                        op,
160                        total_count: total,
161                    })
162                // Text object: `i` → inner, `a` → outer.
163                } else if ch == 'i' {
164                    Outcome::Commit(crate::cmd::EngineCmd::EnterOpTextObj {
165                        op,
166                        count1,
167                        inner: true,
168                    })
169                } else if ch == 'a' {
170                    Outcome::Commit(crate::cmd::EngineCmd::EnterOpTextObj {
171                        op,
172                        count1,
173                        inner: false,
174                    })
175                // g-chord sub-pending (dgg, dgU, etc.).
176                } else if ch == 'g' {
177                    Outcome::Commit(crate::cmd::EngineCmd::EnterOpG { op, count1 })
178                // Find sub-pending (df/dF/dt/dT): transition to OpFind instead
179                // of setting engine Pending::OpFind. `total_count` collapses
180                // both counts at transition time.
181                } else if ch == 'f' {
182                    Outcome::Wait(PendingState::OpFind {
183                        op,
184                        total_count: count1.max(1) * inner_count.max(1),
185                        forward: true,
186                        till: false,
187                    })
188                } else if ch == 'F' {
189                    Outcome::Wait(PendingState::OpFind {
190                        op,
191                        total_count: count1.max(1) * inner_count.max(1),
192                        forward: false,
193                        till: false,
194                    })
195                } else if ch == 't' {
196                    Outcome::Wait(PendingState::OpFind {
197                        op,
198                        total_count: count1.max(1) * inner_count.max(1),
199                        forward: true,
200                        till: true,
201                    })
202                } else if ch == 'T' {
203                    Outcome::Wait(PendingState::OpFind {
204                        op,
205                        total_count: count1.max(1) * inner_count.max(1),
206                        forward: false,
207                        till: true,
208                    })
209                } else {
210                    // All other chars: treat as motion key and let the engine
211                    // parse it via parse_motion. Unknown keys no-op in the engine.
212                    Outcome::Commit(crate::cmd::EngineCmd::ApplyOpMotion {
213                        op,
214                        motion_key: ch,
215                        total_count: total,
216                    })
217                }
218            }
219            // Non-char, non-Esc → cancel (mirrors Find/AfterG arms).
220            _ => Outcome::Cancel,
221        },
222        PendingState::OpFind {
223            op,
224            total_count,
225            forward,
226            till,
227        } => match key {
228            Key::Esc => Outcome::Cancel,
229            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::ApplyOpFind {
230                op,
231                ch,
232                forward,
233                till,
234                total_count,
235            }),
236            // Any non-char key cancels (vim's f<non-char> cancel semantics apply).
237            _ => Outcome::Cancel,
238        },
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::cmd::EngineCmd;
246    use crate::operator::OperatorKind;
247
248    // ── AfterG reducer unit tests ────────────────────────────────────────────
249
250    #[test]
251    fn after_g_gg_commits() {
252        let state = PendingState::AfterG { count: 1 };
253        assert_eq!(
254            step(state, Key::Char('g')),
255            Outcome::Commit(EngineCmd::AfterGChord { ch: 'g', count: 1 })
256        );
257    }
258
259    #[test]
260    fn after_g_gv_commits() {
261        let state = PendingState::AfterG { count: 1 };
262        assert_eq!(
263            step(state, Key::Char('v')),
264            Outcome::Commit(EngineCmd::AfterGChord { ch: 'v', count: 1 })
265        );
266    }
267
268    #[test]
269    fn after_g_gu_operator_commits() {
270        // gU still produces AfterGChord; the engine handles the Pending::Op transition.
271        let state = PendingState::AfterG { count: 1 };
272        assert_eq!(
273            step(state, Key::Char('U')),
274            Outcome::Commit(EngineCmd::AfterGChord { ch: 'U', count: 1 })
275        );
276    }
277
278    #[test]
279    fn after_g_gi_commits() {
280        let state = PendingState::AfterG { count: 1 };
281        assert_eq!(
282            step(state, Key::Char('i')),
283            Outcome::Commit(EngineCmd::AfterGChord { ch: 'i', count: 1 })
284        );
285    }
286
287    #[test]
288    fn after_g_esc_cancels() {
289        let state = PendingState::AfterG { count: 1 };
290        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
291    }
292
293    #[test]
294    fn after_g_count_carry_through() {
295        // 5gg enters with count=5 — AfterGChord carries it through.
296        let state = PendingState::AfterG { count: 5 };
297        assert_eq!(
298            step(state, Key::Char('g')),
299            Outcome::Commit(EngineCmd::AfterGChord { ch: 'g', count: 5 })
300        );
301    }
302
303    #[test]
304    fn after_g_non_char_cancels() {
305        // Non-char, non-Esc key (e.g. Enter) cancels.
306        let state = PendingState::AfterG { count: 1 };
307        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
308    }
309
310    // ── AfterZ reducer unit tests ────────────────────────────────────────────
311
312    #[test]
313    fn after_z_zz_commits() {
314        let state = PendingState::AfterZ { count: 1 };
315        assert_eq!(
316            step(state, Key::Char('z')),
317            Outcome::Commit(EngineCmd::AfterZChord { ch: 'z', count: 1 })
318        );
319    }
320
321    #[test]
322    fn after_z_zf_commits() {
323        let state = PendingState::AfterZ { count: 1 };
324        assert_eq!(
325            step(state, Key::Char('f')),
326            Outcome::Commit(EngineCmd::AfterZChord { ch: 'f', count: 1 })
327        );
328    }
329
330    #[test]
331    fn after_z_esc_cancels() {
332        let state = PendingState::AfterZ { count: 1 };
333        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
334    }
335
336    #[test]
337    fn after_z_count_carry_through() {
338        // 3zz enters with count=3 — AfterZChord carries it through.
339        let state = PendingState::AfterZ { count: 3 };
340        assert_eq!(
341            step(state, Key::Char('z')),
342            Outcome::Commit(EngineCmd::AfterZChord { ch: 'z', count: 3 })
343        );
344    }
345
346    #[test]
347    fn after_z_non_char_cancels() {
348        // Non-char, non-Esc key (e.g. Enter) cancels.
349        let state = PendingState::AfterZ { count: 1 };
350        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
351    }
352
353    // ── AfterOp reducer unit tests ───────────────────────────────────────────
354
355    fn after_op(op: OperatorKind, count1: usize) -> PendingState {
356        PendingState::AfterOp {
357            op,
358            count1,
359            inner_count: 0,
360        }
361    }
362
363    #[test]
364    fn op_d_then_w_commits_motion() {
365        let state = after_op(OperatorKind::Delete, 1);
366        assert_eq!(
367            step(state, Key::Char('w')),
368            Outcome::Commit(EngineCmd::ApplyOpMotion {
369                op: OperatorKind::Delete,
370                motion_key: 'w',
371                total_count: 1,
372            })
373        );
374    }
375
376    #[test]
377    fn op_d_then_d_commits_double() {
378        let state = after_op(OperatorKind::Delete, 1);
379        assert_eq!(
380            step(state, Key::Char('d')),
381            Outcome::Commit(EngineCmd::ApplyOpDouble {
382                op: OperatorKind::Delete,
383                total_count: 1,
384            })
385        );
386    }
387
388    #[test]
389    fn op_d_inner_count_d3w_commits_motion_with_count_3() {
390        // d3w: count1=1, inner_count accumulates to 3, total=3.
391        let state = after_op(OperatorKind::Delete, 1);
392        // Type '3'.
393        let Outcome::Wait(state2) = step(state, Key::Char('3')) else {
394            panic!("expected Wait");
395        };
396        assert_eq!(
397            state2,
398            PendingState::AfterOp {
399                op: OperatorKind::Delete,
400                count1: 1,
401                inner_count: 3
402            }
403        );
404        // Type 'w'.
405        assert_eq!(
406            step(state2, Key::Char('w')),
407            Outcome::Commit(EngineCmd::ApplyOpMotion {
408                op: OperatorKind::Delete,
409                motion_key: 'w',
410                total_count: 3,
411            })
412        );
413    }
414
415    #[test]
416    fn op_2d_d_commits_double_with_count_2() {
417        // 2dd: count1=2, inner_count=0, doubled → total=2.
418        let state = after_op(OperatorKind::Delete, 2);
419        assert_eq!(
420            step(state, Key::Char('d')),
421            Outcome::Commit(EngineCmd::ApplyOpDouble {
422                op: OperatorKind::Delete,
423                total_count: 2,
424            })
425        );
426    }
427
428    #[test]
429    fn op_2d_3w_commits_motion_with_total_6() {
430        // 2d3w: count1=2, inner=3, total=6.
431        let state = after_op(OperatorKind::Delete, 2);
432        let Outcome::Wait(state2) = step(state, Key::Char('3')) else {
433            panic!("expected Wait");
434        };
435        assert_eq!(
436            step(state2, Key::Char('w')),
437            Outcome::Commit(EngineCmd::ApplyOpMotion {
438                op: OperatorKind::Delete,
439                motion_key: 'w',
440                total_count: 6,
441            })
442        );
443    }
444
445    #[test]
446    fn op_d_then_i_emits_enter_op_text_obj_inner_true() {
447        let state = after_op(OperatorKind::Delete, 1);
448        assert_eq!(
449            step(state, Key::Char('i')),
450            Outcome::Commit(EngineCmd::EnterOpTextObj {
451                op: OperatorKind::Delete,
452                count1: 1,
453                inner: true,
454            })
455        );
456    }
457
458    #[test]
459    fn op_d_then_a_emits_enter_op_text_obj_inner_false() {
460        let state = after_op(OperatorKind::Delete, 1);
461        assert_eq!(
462            step(state, Key::Char('a')),
463            Outcome::Commit(EngineCmd::EnterOpTextObj {
464                op: OperatorKind::Delete,
465                count1: 1,
466                inner: false,
467            })
468        );
469    }
470
471    #[test]
472    fn op_d_then_g_emits_enter_op_g() {
473        let state = after_op(OperatorKind::Delete, 1);
474        assert_eq!(
475            step(state, Key::Char('g')),
476            Outcome::Commit(EngineCmd::EnterOpG {
477                op: OperatorKind::Delete,
478                count1: 1,
479            })
480        );
481    }
482
483    #[test]
484    fn op_d_then_f_transitions_to_op_find_forward_not_till() {
485        // `df` → Wait(OpFind { forward:true, till:false, total_count:1 })
486        let state = after_op(OperatorKind::Delete, 1);
487        assert_eq!(
488            step(state, Key::Char('f')),
489            Outcome::Wait(PendingState::OpFind {
490                op: OperatorKind::Delete,
491                total_count: 1,
492                forward: true,
493                till: false,
494            })
495        );
496    }
497
498    #[test]
499    fn op_d_then_cap_f_transitions_to_op_find_backward_not_till() {
500        // `dF` → Wait(OpFind { forward:false, till:false, total_count:1 })
501        let state = after_op(OperatorKind::Delete, 1);
502        assert_eq!(
503            step(state, Key::Char('F')),
504            Outcome::Wait(PendingState::OpFind {
505                op: OperatorKind::Delete,
506                total_count: 1,
507                forward: false,
508                till: false,
509            })
510        );
511    }
512
513    #[test]
514    fn op_d_then_t_transitions_to_op_find_forward_till() {
515        // `dt` → Wait(OpFind { forward:true, till:true, total_count:1 })
516        let state = after_op(OperatorKind::Delete, 1);
517        assert_eq!(
518            step(state, Key::Char('t')),
519            Outcome::Wait(PendingState::OpFind {
520                op: OperatorKind::Delete,
521                total_count: 1,
522                forward: true,
523                till: true,
524            })
525        );
526    }
527
528    #[test]
529    fn op_d_then_cap_t_transitions_to_op_find_backward_till() {
530        // `dT` → Wait(OpFind { forward:false, till:true, total_count:1 })
531        let state = after_op(OperatorKind::Delete, 1);
532        assert_eq!(
533            step(state, Key::Char('T')),
534            Outcome::Wait(PendingState::OpFind {
535                op: OperatorKind::Delete,
536                total_count: 1,
537                forward: false,
538                till: true,
539            })
540        );
541    }
542
543    // ── OpFind reducer unit tests ────────────────────────────────────────────
544
545    fn op_find(op: OperatorKind, total_count: usize, forward: bool, till: bool) -> PendingState {
546        PendingState::OpFind {
547            op,
548            total_count,
549            forward,
550            till,
551        }
552    }
553
554    #[test]
555    fn op_d_then_f_then_x_commits_apply_op_find() {
556        // `dfx` → ApplyOpFind { Delete, 'x', forward:true, till:false, total:1 }
557        let state = op_find(OperatorKind::Delete, 1, true, false);
558        assert_eq!(
559            step(state, Key::Char('x')),
560            Outcome::Commit(EngineCmd::ApplyOpFind {
561                op: OperatorKind::Delete,
562                ch: 'x',
563                forward: true,
564                till: false,
565                total_count: 1,
566            })
567        );
568    }
569
570    #[test]
571    fn op_d_then_cap_f_then_x_commits_apply_op_find_backward() {
572        // `dFx` → ApplyOpFind { Delete, 'x', forward:false, till:false, total:1 }
573        let state = op_find(OperatorKind::Delete, 1, false, false);
574        assert_eq!(
575            step(state, Key::Char('x')),
576            Outcome::Commit(EngineCmd::ApplyOpFind {
577                op: OperatorKind::Delete,
578                ch: 'x',
579                forward: false,
580                till: false,
581                total_count: 1,
582            })
583        );
584    }
585
586    #[test]
587    fn op_d_then_t_then_x_commits_apply_op_find_till() {
588        // `dtx` → ApplyOpFind { Delete, 'x', forward:true, till:true, total:1 }
589        let state = op_find(OperatorKind::Delete, 1, true, true);
590        assert_eq!(
591            step(state, Key::Char('x')),
592            Outcome::Commit(EngineCmd::ApplyOpFind {
593                op: OperatorKind::Delete,
594                ch: 'x',
595                forward: true,
596                till: true,
597                total_count: 1,
598            })
599        );
600    }
601
602    #[test]
603    fn op_d_then_cap_t_then_x_commits_apply_op_find_backward_till() {
604        // `dTx` → ApplyOpFind { Delete, 'x', forward:false, till:true, total:1 }
605        let state = op_find(OperatorKind::Delete, 1, false, true);
606        assert_eq!(
607            step(state, Key::Char('x')),
608            Outcome::Commit(EngineCmd::ApplyOpFind {
609                op: OperatorKind::Delete,
610                ch: 'x',
611                forward: false,
612                till: true,
613                total_count: 1,
614            })
615        );
616    }
617
618    #[test]
619    fn op_2d_3f_x_commits_total_count_6() {
620        // `2d3fx`: count1=2, inner_count=3 → total=6 folded at AfterOp→OpFind.
621        // Simulate via AfterOp(count1=2, inner_count=3) then 'f', then 'x'.
622        let state = PendingState::AfterOp {
623            op: OperatorKind::Delete,
624            count1: 2,
625            inner_count: 3,
626        };
627        let Outcome::Wait(op_find_state) = step(state, Key::Char('f')) else {
628            panic!("expected Wait(OpFind)");
629        };
630        assert_eq!(
631            op_find_state,
632            PendingState::OpFind {
633                op: OperatorKind::Delete,
634                total_count: 6,
635                forward: true,
636                till: false,
637            }
638        );
639        assert_eq!(
640            step(op_find_state, Key::Char('x')),
641            Outcome::Commit(EngineCmd::ApplyOpFind {
642                op: OperatorKind::Delete,
643                ch: 'x',
644                forward: true,
645                till: false,
646                total_count: 6,
647            })
648        );
649    }
650
651    #[test]
652    fn op_d_f_then_esc_cancels() {
653        // `df<Esc>` — vim cancels f<Esc>, so OpFind on Esc → Cancel.
654        let state = op_find(OperatorKind::Delete, 1, true, false);
655        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
656    }
657
658    #[test]
659    fn op_d_f_then_enter_cancels() {
660        // Non-char key after `df` cancels (mirrors Find arm).
661        let state = op_find(OperatorKind::Delete, 1, true, false);
662        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
663    }
664
665    #[test]
666    fn op_d_then_esc_cancels() {
667        let state = after_op(OperatorKind::Delete, 1);
668        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
669    }
670
671    #[test]
672    fn op_d_non_char_cancels() {
673        let state = after_op(OperatorKind::Delete, 1);
674        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
675    }
676
677    #[test]
678    fn op_d_bare_zero_is_line_start_motion() {
679        // Bare '0' with inner_count=0 → LineStart motion (total=1).
680        let state = after_op(OperatorKind::Delete, 1);
681        assert_eq!(
682            step(state, Key::Char('0')),
683            Outcome::Commit(EngineCmd::ApplyOpMotion {
684                op: OperatorKind::Delete,
685                motion_key: '0',
686                total_count: 1,
687            })
688        );
689    }
690
691    #[test]
692    fn op_d_zero_accumulates_when_inner_count_nonzero() {
693        // d10w: '1' accumulates to inner=1, then '0' accumulates (inner>0) to inner=10.
694        let state = after_op(OperatorKind::Delete, 1);
695        let Outcome::Wait(s2) = step(state, Key::Char('1')) else {
696            panic!("expected Wait");
697        };
698        let Outcome::Wait(s3) = step(s2, Key::Char('0')) else {
699            panic!("expected Wait");
700        };
701        assert_eq!(
702            s3,
703            PendingState::AfterOp {
704                op: OperatorKind::Delete,
705                count1: 1,
706                inner_count: 10,
707            }
708        );
709        assert_eq!(
710            step(s3, Key::Char('w')),
711            Outcome::Commit(EngineCmd::ApplyOpMotion {
712                op: OperatorKind::Delete,
713                motion_key: 'w',
714                total_count: 10,
715            })
716        );
717    }
718
719    // Per-operator round-trip tests.
720
721    #[test]
722    fn op_yank_doubled() {
723        let state = after_op(OperatorKind::Yank, 1);
724        assert_eq!(
725            step(state, Key::Char('y')),
726            Outcome::Commit(EngineCmd::ApplyOpDouble {
727                op: OperatorKind::Yank,
728                total_count: 1,
729            })
730        );
731    }
732
733    #[test]
734    fn op_change_doubled() {
735        let state = after_op(OperatorKind::Change, 1);
736        assert_eq!(
737            step(state, Key::Char('c')),
738            Outcome::Commit(EngineCmd::ApplyOpDouble {
739                op: OperatorKind::Change,
740                total_count: 1,
741            })
742        );
743    }
744
745    #[test]
746    fn op_indent_doubled() {
747        let state = after_op(OperatorKind::Indent, 1);
748        assert_eq!(
749            step(state, Key::Char('>')),
750            Outcome::Commit(EngineCmd::ApplyOpDouble {
751                op: OperatorKind::Indent,
752                total_count: 1,
753            })
754        );
755    }
756
757    #[test]
758    fn op_outdent_doubled() {
759        let state = after_op(OperatorKind::Outdent, 1);
760        assert_eq!(
761            step(state, Key::Char('<')),
762            Outcome::Commit(EngineCmd::ApplyOpDouble {
763                op: OperatorKind::Outdent,
764                total_count: 1,
765            })
766        );
767    }
768
769    #[test]
770    fn op_yank_motion() {
771        let state = after_op(OperatorKind::Yank, 1);
772        assert_eq!(
773            step(state, Key::Char('$')),
774            Outcome::Commit(EngineCmd::ApplyOpMotion {
775                op: OperatorKind::Yank,
776                motion_key: '$',
777                total_count: 1,
778            })
779        );
780    }
781
782    #[test]
783    fn op_change_motion() {
784        let state = after_op(OperatorKind::Change, 1);
785        assert_eq!(
786            step(state, Key::Char('w')),
787            Outcome::Commit(EngineCmd::ApplyOpMotion {
788                op: OperatorKind::Change,
789                motion_key: 'w',
790                total_count: 1,
791            })
792        );
793    }
794
795    #[test]
796    fn op_indent_motion() {
797        let state = after_op(OperatorKind::Indent, 1);
798        assert_eq!(
799            step(state, Key::Char('j')),
800            Outcome::Commit(EngineCmd::ApplyOpMotion {
801                op: OperatorKind::Indent,
802                motion_key: 'j',
803                total_count: 1,
804            })
805        );
806    }
807
808    #[test]
809    fn op_outdent_motion() {
810        let state = after_op(OperatorKind::Outdent, 1);
811        assert_eq!(
812            step(state, Key::Char('k')),
813            Outcome::Commit(EngineCmd::ApplyOpMotion {
814                op: OperatorKind::Outdent,
815                motion_key: 'k',
816                total_count: 1,
817            })
818        );
819    }
820}