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    /// `di<x>` / `da<x>` etc. — reached from `AfterOp` when next key after
64    /// operator is `i` or `a`. `total_count = count1 * inner_count` already
65    /// folded; engine ignores it for text-object motions but it's passed
66    /// through for future-proofing / consistency with `OpFind` shape.
67    OpTextObj {
68        op: crate::operator::OperatorKind,
69        total_count: usize,
70        inner: bool,
71    },
72    /// `dgg` / `dge` / `dgE` / `dgj` / `dgk` etc. — reached from `AfterOp`
73    /// when next key after operator is `g`. For case-ops (gu/gU/g~) the
74    /// doubled form (gUgU = gUU linewise) is dispatched here too — engine
75    /// detects via op-matching second char.
76    OpG {
77        op: crate::operator::OperatorKind,
78        total_count: usize,
79    },
80    // 2c–2e sub-state variants continue to land in later chunks.
81}
82
83/// One step of the reducer.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum Outcome {
86    /// Need more keys — keep accumulating with new state.
87    Wait(PendingState),
88    /// Run this engine command, then clear pending.
89    Commit(crate::cmd::EngineCmd),
90    /// Cancel pending (Esc, invalid char, etc.). No engine call.
91    Cancel,
92    /// Pending state didn't consume this key — host should route it
93    /// normally (e.g. modifier-only key). Pending state stays alive.
94    Forward,
95}
96
97/// `Key` is intentionally minimal — hjkl-vim should not depend on
98/// crossterm. Hosts translate their native keys into this shape.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum Key {
101    Char(char),
102    Esc,
103    Enter,
104    Backspace,
105    Tab,
106    // Add more variants only as later chunks require them.
107}
108
109pub fn step(state: PendingState, key: Key) -> Outcome {
110    match state {
111        PendingState::Replace { count } => match key {
112            Key::Esc => Outcome::Cancel,
113            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::ReplaceChar { ch, count }),
114            Key::Enter => Outcome::Commit(crate::cmd::EngineCmd::ReplaceChar { ch: '\n', count }),
115            _ => Outcome::Cancel,
116        },
117        PendingState::Find {
118            count,
119            forward,
120            till,
121        } => match key {
122            Key::Esc => Outcome::Cancel,
123            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::FindChar {
124                ch,
125                forward,
126                till,
127                count,
128            }),
129            // Any non-char key cancels (vim cancels f<non-char>).
130            _ => Outcome::Cancel,
131        },
132        PendingState::AfterG { count } => match key {
133            Key::Esc => Outcome::Cancel,
134            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::AfterGChord { ch, count }),
135            // Any non-char key cancels (mirrors Find arm).
136            _ => Outcome::Cancel,
137        },
138        PendingState::AfterZ { count } => match key {
139            Key::Esc => Outcome::Cancel,
140            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::AfterZChord { ch, count }),
141            // Any non-char key cancels (mirrors AfterG arm).
142            _ => Outcome::Cancel,
143        },
144        PendingState::AfterOp {
145            op,
146            count1,
147            inner_count,
148        } => match key {
149            Key::Esc => Outcome::Cancel,
150            Key::Char(d @ '0'..='9') => {
151                // Vim quirk: bare `0` with inner_count==0 is LineStart motion.
152                if d == '0' && inner_count == 0 {
153                    // Treat as motion key — engine will parse '0' as LineStart.
154                    let total = count1.max(1);
155                    Outcome::Commit(crate::cmd::EngineCmd::ApplyOpMotion {
156                        op,
157                        motion_key: '0',
158                        total_count: total,
159                    })
160                } else {
161                    let new_inner = inner_count
162                        .saturating_mul(10)
163                        .saturating_add(d as usize - '0' as usize);
164                    Outcome::Wait(PendingState::AfterOp {
165                        op,
166                        count1,
167                        inner_count: new_inner,
168                    })
169                }
170            }
171            Key::Char(ch) => {
172                let total = count1.max(1) * inner_count.max(1);
173                // Doubled letter → line op (dd/yy/cc/>>/<<).
174                if ch == op.double_char() {
175                    Outcome::Commit(crate::cmd::EngineCmd::ApplyOpDouble {
176                        op,
177                        total_count: total,
178                    })
179                // Text object: `i` → inner, `a` → outer. Transition to
180                // `OpTextObj` so the reducer owns the next char instead of
181                // delegating to the engine FSM (mirrors OpFind pattern).
182                } else if ch == 'i' {
183                    Outcome::Wait(PendingState::OpTextObj {
184                        op,
185                        total_count: count1.max(1) * inner_count.max(1),
186                        inner: true,
187                    })
188                } else if ch == 'a' {
189                    Outcome::Wait(PendingState::OpTextObj {
190                        op,
191                        total_count: count1.max(1) * inner_count.max(1),
192                        inner: false,
193                    })
194                // g-chord sub-pending (dgg, dge, etc.): transition to OpG so
195                // the reducer owns the second char instead of delegating to the
196                // engine FSM. `total_count` collapses both counts at transition
197                // time (mirrors OpFind / OpTextObj pattern).
198                } else if ch == 'g' {
199                    Outcome::Wait(PendingState::OpG {
200                        op,
201                        total_count: count1.max(1) * inner_count.max(1),
202                    })
203                // Find sub-pending (df/dF/dt/dT): transition to OpFind instead
204                // of setting engine Pending::OpFind. `total_count` collapses
205                // both counts at transition time.
206                } else if ch == 'f' {
207                    Outcome::Wait(PendingState::OpFind {
208                        op,
209                        total_count: count1.max(1) * inner_count.max(1),
210                        forward: true,
211                        till: false,
212                    })
213                } else if ch == 'F' {
214                    Outcome::Wait(PendingState::OpFind {
215                        op,
216                        total_count: count1.max(1) * inner_count.max(1),
217                        forward: false,
218                        till: false,
219                    })
220                } else if ch == 't' {
221                    Outcome::Wait(PendingState::OpFind {
222                        op,
223                        total_count: count1.max(1) * inner_count.max(1),
224                        forward: true,
225                        till: true,
226                    })
227                } else if ch == 'T' {
228                    Outcome::Wait(PendingState::OpFind {
229                        op,
230                        total_count: count1.max(1) * inner_count.max(1),
231                        forward: false,
232                        till: true,
233                    })
234                } else {
235                    // All other chars: treat as motion key and let the engine
236                    // parse it via parse_motion. Unknown keys no-op in the engine.
237                    Outcome::Commit(crate::cmd::EngineCmd::ApplyOpMotion {
238                        op,
239                        motion_key: ch,
240                        total_count: total,
241                    })
242                }
243            }
244            // Non-char, non-Esc → cancel (mirrors Find/AfterG arms).
245            _ => Outcome::Cancel,
246        },
247        PendingState::OpFind {
248            op,
249            total_count,
250            forward,
251            till,
252        } => match key {
253            Key::Esc => Outcome::Cancel,
254            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::ApplyOpFind {
255                op,
256                ch,
257                forward,
258                till,
259                total_count,
260            }),
261            // Any non-char key cancels (vim's f<non-char> cancel semantics apply).
262            _ => Outcome::Cancel,
263        },
264        PendingState::OpTextObj {
265            op,
266            total_count,
267            inner,
268        } => match key {
269            Key::Esc => Outcome::Cancel,
270            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::ApplyOpTextObj {
271                op,
272                ch,
273                inner,
274                total_count,
275            }),
276            // Any non-char key cancels; engine handles invalid chars as no-ops.
277            _ => Outcome::Cancel,
278        },
279        PendingState::OpG { op, total_count } => match key {
280            Key::Esc => Outcome::Cancel,
281            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::ApplyOpG {
282                op,
283                ch,
284                total_count,
285            }),
286            // Any non-char key cancels; engine apply_op_g handles unknown chars
287            // as a no-op (mirrors OpTextObj arm).
288            _ => Outcome::Cancel,
289        },
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::cmd::EngineCmd;
297    use crate::operator::OperatorKind;
298
299    // ── AfterG reducer unit tests ────────────────────────────────────────────
300
301    #[test]
302    fn after_g_gg_commits() {
303        let state = PendingState::AfterG { count: 1 };
304        assert_eq!(
305            step(state, Key::Char('g')),
306            Outcome::Commit(EngineCmd::AfterGChord { ch: 'g', count: 1 })
307        );
308    }
309
310    #[test]
311    fn after_g_gv_commits() {
312        let state = PendingState::AfterG { count: 1 };
313        assert_eq!(
314            step(state, Key::Char('v')),
315            Outcome::Commit(EngineCmd::AfterGChord { ch: 'v', count: 1 })
316        );
317    }
318
319    #[test]
320    fn after_g_gu_operator_commits() {
321        // gU still produces AfterGChord; the engine handles the Pending::Op transition.
322        let state = PendingState::AfterG { count: 1 };
323        assert_eq!(
324            step(state, Key::Char('U')),
325            Outcome::Commit(EngineCmd::AfterGChord { ch: 'U', count: 1 })
326        );
327    }
328
329    #[test]
330    fn after_g_gi_commits() {
331        let state = PendingState::AfterG { count: 1 };
332        assert_eq!(
333            step(state, Key::Char('i')),
334            Outcome::Commit(EngineCmd::AfterGChord { ch: 'i', count: 1 })
335        );
336    }
337
338    #[test]
339    fn after_g_esc_cancels() {
340        let state = PendingState::AfterG { count: 1 };
341        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
342    }
343
344    #[test]
345    fn after_g_count_carry_through() {
346        // 5gg enters with count=5 — AfterGChord carries it through.
347        let state = PendingState::AfterG { count: 5 };
348        assert_eq!(
349            step(state, Key::Char('g')),
350            Outcome::Commit(EngineCmd::AfterGChord { ch: 'g', count: 5 })
351        );
352    }
353
354    #[test]
355    fn after_g_non_char_cancels() {
356        // Non-char, non-Esc key (e.g. Enter) cancels.
357        let state = PendingState::AfterG { count: 1 };
358        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
359    }
360
361    // ── AfterZ reducer unit tests ────────────────────────────────────────────
362
363    #[test]
364    fn after_z_zz_commits() {
365        let state = PendingState::AfterZ { count: 1 };
366        assert_eq!(
367            step(state, Key::Char('z')),
368            Outcome::Commit(EngineCmd::AfterZChord { ch: 'z', count: 1 })
369        );
370    }
371
372    #[test]
373    fn after_z_zf_commits() {
374        let state = PendingState::AfterZ { count: 1 };
375        assert_eq!(
376            step(state, Key::Char('f')),
377            Outcome::Commit(EngineCmd::AfterZChord { ch: 'f', count: 1 })
378        );
379    }
380
381    #[test]
382    fn after_z_esc_cancels() {
383        let state = PendingState::AfterZ { count: 1 };
384        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
385    }
386
387    #[test]
388    fn after_z_count_carry_through() {
389        // 3zz enters with count=3 — AfterZChord carries it through.
390        let state = PendingState::AfterZ { count: 3 };
391        assert_eq!(
392            step(state, Key::Char('z')),
393            Outcome::Commit(EngineCmd::AfterZChord { ch: 'z', count: 3 })
394        );
395    }
396
397    #[test]
398    fn after_z_non_char_cancels() {
399        // Non-char, non-Esc key (e.g. Enter) cancels.
400        let state = PendingState::AfterZ { count: 1 };
401        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
402    }
403
404    // ── AfterOp reducer unit tests ───────────────────────────────────────────
405
406    fn after_op(op: OperatorKind, count1: usize) -> PendingState {
407        PendingState::AfterOp {
408            op,
409            count1,
410            inner_count: 0,
411        }
412    }
413
414    #[test]
415    fn op_d_then_w_commits_motion() {
416        let state = after_op(OperatorKind::Delete, 1);
417        assert_eq!(
418            step(state, Key::Char('w')),
419            Outcome::Commit(EngineCmd::ApplyOpMotion {
420                op: OperatorKind::Delete,
421                motion_key: 'w',
422                total_count: 1,
423            })
424        );
425    }
426
427    #[test]
428    fn op_d_then_d_commits_double() {
429        let state = after_op(OperatorKind::Delete, 1);
430        assert_eq!(
431            step(state, Key::Char('d')),
432            Outcome::Commit(EngineCmd::ApplyOpDouble {
433                op: OperatorKind::Delete,
434                total_count: 1,
435            })
436        );
437    }
438
439    #[test]
440    fn op_d_inner_count_d3w_commits_motion_with_count_3() {
441        // d3w: count1=1, inner_count accumulates to 3, total=3.
442        let state = after_op(OperatorKind::Delete, 1);
443        // Type '3'.
444        let Outcome::Wait(state2) = step(state, Key::Char('3')) else {
445            panic!("expected Wait");
446        };
447        assert_eq!(
448            state2,
449            PendingState::AfterOp {
450                op: OperatorKind::Delete,
451                count1: 1,
452                inner_count: 3
453            }
454        );
455        // Type 'w'.
456        assert_eq!(
457            step(state2, Key::Char('w')),
458            Outcome::Commit(EngineCmd::ApplyOpMotion {
459                op: OperatorKind::Delete,
460                motion_key: 'w',
461                total_count: 3,
462            })
463        );
464    }
465
466    #[test]
467    fn op_2d_d_commits_double_with_count_2() {
468        // 2dd: count1=2, inner_count=0, doubled → total=2.
469        let state = after_op(OperatorKind::Delete, 2);
470        assert_eq!(
471            step(state, Key::Char('d')),
472            Outcome::Commit(EngineCmd::ApplyOpDouble {
473                op: OperatorKind::Delete,
474                total_count: 2,
475            })
476        );
477    }
478
479    #[test]
480    fn op_2d_3w_commits_motion_with_total_6() {
481        // 2d3w: count1=2, inner=3, total=6.
482        let state = after_op(OperatorKind::Delete, 2);
483        let Outcome::Wait(state2) = step(state, Key::Char('3')) else {
484            panic!("expected Wait");
485        };
486        assert_eq!(
487            step(state2, Key::Char('w')),
488            Outcome::Commit(EngineCmd::ApplyOpMotion {
489                op: OperatorKind::Delete,
490                motion_key: 'w',
491                total_count: 6,
492            })
493        );
494    }
495
496    #[test]
497    fn op_d_then_i_transitions_to_op_text_obj_inner() {
498        // `di` → Wait(OpTextObj { inner:true, total_count:1 })
499        let state = after_op(OperatorKind::Delete, 1);
500        assert_eq!(
501            step(state, Key::Char('i')),
502            Outcome::Wait(PendingState::OpTextObj {
503                op: OperatorKind::Delete,
504                total_count: 1,
505                inner: true,
506            })
507        );
508    }
509
510    #[test]
511    fn op_d_then_a_transitions_to_op_text_obj_around() {
512        // `da` → Wait(OpTextObj { inner:false, total_count:1 })
513        let state = after_op(OperatorKind::Delete, 1);
514        assert_eq!(
515            step(state, Key::Char('a')),
516            Outcome::Wait(PendingState::OpTextObj {
517                op: OperatorKind::Delete,
518                total_count: 1,
519                inner: false,
520            })
521        );
522    }
523
524    #[test]
525    fn op_d_then_g_transitions_to_op_g() {
526        let state = after_op(OperatorKind::Delete, 1);
527        assert_eq!(
528            step(state, Key::Char('g')),
529            Outcome::Wait(PendingState::OpG {
530                op: OperatorKind::Delete,
531                total_count: 1,
532            })
533        );
534    }
535
536    #[test]
537    fn op_d_then_f_transitions_to_op_find_forward_not_till() {
538        // `df` → Wait(OpFind { forward:true, till:false, total_count:1 })
539        let state = after_op(OperatorKind::Delete, 1);
540        assert_eq!(
541            step(state, Key::Char('f')),
542            Outcome::Wait(PendingState::OpFind {
543                op: OperatorKind::Delete,
544                total_count: 1,
545                forward: true,
546                till: false,
547            })
548        );
549    }
550
551    #[test]
552    fn op_d_then_cap_f_transitions_to_op_find_backward_not_till() {
553        // `dF` → Wait(OpFind { forward:false, till:false, total_count:1 })
554        let state = after_op(OperatorKind::Delete, 1);
555        assert_eq!(
556            step(state, Key::Char('F')),
557            Outcome::Wait(PendingState::OpFind {
558                op: OperatorKind::Delete,
559                total_count: 1,
560                forward: false,
561                till: false,
562            })
563        );
564    }
565
566    #[test]
567    fn op_d_then_t_transitions_to_op_find_forward_till() {
568        // `dt` → Wait(OpFind { forward:true, till:true, total_count:1 })
569        let state = after_op(OperatorKind::Delete, 1);
570        assert_eq!(
571            step(state, Key::Char('t')),
572            Outcome::Wait(PendingState::OpFind {
573                op: OperatorKind::Delete,
574                total_count: 1,
575                forward: true,
576                till: true,
577            })
578        );
579    }
580
581    #[test]
582    fn op_d_then_cap_t_transitions_to_op_find_backward_till() {
583        // `dT` → Wait(OpFind { forward:false, till:true, total_count:1 })
584        let state = after_op(OperatorKind::Delete, 1);
585        assert_eq!(
586            step(state, Key::Char('T')),
587            Outcome::Wait(PendingState::OpFind {
588                op: OperatorKind::Delete,
589                total_count: 1,
590                forward: false,
591                till: true,
592            })
593        );
594    }
595
596    // ── OpFind reducer unit tests ────────────────────────────────────────────
597
598    fn op_find(op: OperatorKind, total_count: usize, forward: bool, till: bool) -> PendingState {
599        PendingState::OpFind {
600            op,
601            total_count,
602            forward,
603            till,
604        }
605    }
606
607    #[test]
608    fn op_d_then_f_then_x_commits_apply_op_find() {
609        // `dfx` → ApplyOpFind { Delete, 'x', forward:true, till:false, total:1 }
610        let state = op_find(OperatorKind::Delete, 1, true, false);
611        assert_eq!(
612            step(state, Key::Char('x')),
613            Outcome::Commit(EngineCmd::ApplyOpFind {
614                op: OperatorKind::Delete,
615                ch: 'x',
616                forward: true,
617                till: false,
618                total_count: 1,
619            })
620        );
621    }
622
623    #[test]
624    fn op_d_then_cap_f_then_x_commits_apply_op_find_backward() {
625        // `dFx` → ApplyOpFind { Delete, 'x', forward:false, till:false, total:1 }
626        let state = op_find(OperatorKind::Delete, 1, false, false);
627        assert_eq!(
628            step(state, Key::Char('x')),
629            Outcome::Commit(EngineCmd::ApplyOpFind {
630                op: OperatorKind::Delete,
631                ch: 'x',
632                forward: false,
633                till: false,
634                total_count: 1,
635            })
636        );
637    }
638
639    #[test]
640    fn op_d_then_t_then_x_commits_apply_op_find_till() {
641        // `dtx` → ApplyOpFind { Delete, 'x', forward:true, till:true, total:1 }
642        let state = op_find(OperatorKind::Delete, 1, true, true);
643        assert_eq!(
644            step(state, Key::Char('x')),
645            Outcome::Commit(EngineCmd::ApplyOpFind {
646                op: OperatorKind::Delete,
647                ch: 'x',
648                forward: true,
649                till: true,
650                total_count: 1,
651            })
652        );
653    }
654
655    #[test]
656    fn op_d_then_cap_t_then_x_commits_apply_op_find_backward_till() {
657        // `dTx` → ApplyOpFind { Delete, 'x', forward:false, till:true, total:1 }
658        let state = op_find(OperatorKind::Delete, 1, false, true);
659        assert_eq!(
660            step(state, Key::Char('x')),
661            Outcome::Commit(EngineCmd::ApplyOpFind {
662                op: OperatorKind::Delete,
663                ch: 'x',
664                forward: false,
665                till: true,
666                total_count: 1,
667            })
668        );
669    }
670
671    #[test]
672    fn op_2d_3f_x_commits_total_count_6() {
673        // `2d3fx`: count1=2, inner_count=3 → total=6 folded at AfterOp→OpFind.
674        // Simulate via AfterOp(count1=2, inner_count=3) then 'f', then 'x'.
675        let state = PendingState::AfterOp {
676            op: OperatorKind::Delete,
677            count1: 2,
678            inner_count: 3,
679        };
680        let Outcome::Wait(op_find_state) = step(state, Key::Char('f')) else {
681            panic!("expected Wait(OpFind)");
682        };
683        assert_eq!(
684            op_find_state,
685            PendingState::OpFind {
686                op: OperatorKind::Delete,
687                total_count: 6,
688                forward: true,
689                till: false,
690            }
691        );
692        assert_eq!(
693            step(op_find_state, Key::Char('x')),
694            Outcome::Commit(EngineCmd::ApplyOpFind {
695                op: OperatorKind::Delete,
696                ch: 'x',
697                forward: true,
698                till: false,
699                total_count: 6,
700            })
701        );
702    }
703
704    #[test]
705    fn op_d_f_then_esc_cancels() {
706        // `df<Esc>` — vim cancels f<Esc>, so OpFind on Esc → Cancel.
707        let state = op_find(OperatorKind::Delete, 1, true, false);
708        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
709    }
710
711    #[test]
712    fn op_d_f_then_enter_cancels() {
713        // Non-char key after `df` cancels (mirrors Find arm).
714        let state = op_find(OperatorKind::Delete, 1, true, false);
715        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
716    }
717
718    #[test]
719    fn op_d_then_esc_cancels() {
720        let state = after_op(OperatorKind::Delete, 1);
721        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
722    }
723
724    #[test]
725    fn op_d_non_char_cancels() {
726        let state = after_op(OperatorKind::Delete, 1);
727        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
728    }
729
730    // ── OpTextObj reducer unit tests ─────────────────────────────────────────
731
732    fn op_text_obj(op: OperatorKind, total_count: usize, inner: bool) -> PendingState {
733        PendingState::OpTextObj {
734            op,
735            total_count,
736            inner,
737        }
738    }
739
740    #[test]
741    fn op_d_then_i_then_w_commits_apply_op_text_obj_inner() {
742        // `diw` → ApplyOpTextObj { Delete, 'w', inner:true, total_count:1 }
743        let state = op_text_obj(OperatorKind::Delete, 1, true);
744        assert_eq!(
745            step(state, Key::Char('w')),
746            Outcome::Commit(EngineCmd::ApplyOpTextObj {
747                op: OperatorKind::Delete,
748                ch: 'w',
749                inner: true,
750                total_count: 1,
751            })
752        );
753    }
754
755    #[test]
756    fn op_d_then_a_then_w_commits_apply_op_text_obj_around() {
757        // `daw` → ApplyOpTextObj { Delete, 'w', inner:false, total_count:1 }
758        let state = op_text_obj(OperatorKind::Delete, 1, false);
759        assert_eq!(
760            step(state, Key::Char('w')),
761            Outcome::Commit(EngineCmd::ApplyOpTextObj {
762                op: OperatorKind::Delete,
763                ch: 'w',
764                inner: false,
765                total_count: 1,
766            })
767        );
768    }
769
770    #[test]
771    fn op_d_then_i_then_quote_commits_with_quote_char() {
772        // `di"` → ApplyOpTextObj { Delete, '"', inner:true, total_count:1 }
773        let state = op_text_obj(OperatorKind::Delete, 1, true);
774        assert_eq!(
775            step(state, Key::Char('"')),
776            Outcome::Commit(EngineCmd::ApplyOpTextObj {
777                op: OperatorKind::Delete,
778                ch: '"',
779                inner: true,
780                total_count: 1,
781            })
782        );
783    }
784
785    #[test]
786    fn op_d_then_i_then_paren_commits_with_paren() {
787        // `di(` → ApplyOpTextObj { Delete, '(', inner:true, total_count:1 }
788        let state = op_text_obj(OperatorKind::Delete, 1, true);
789        assert_eq!(
790            step(state, Key::Char('(')),
791            Outcome::Commit(EngineCmd::ApplyOpTextObj {
792                op: OperatorKind::Delete,
793                ch: '(',
794                inner: true,
795                total_count: 1,
796            })
797        );
798    }
799
800    #[test]
801    fn op_c_then_i_then_p_commits_change_paragraph_inner() {
802        // `cip` → ApplyOpTextObj { Change, 'p', inner:true, total_count:1 }
803        let state = op_text_obj(OperatorKind::Change, 1, true);
804        assert_eq!(
805            step(state, Key::Char('p')),
806            Outcome::Commit(EngineCmd::ApplyOpTextObj {
807                op: OperatorKind::Change,
808                ch: 'p',
809                inner: true,
810                total_count: 1,
811            })
812        );
813    }
814
815    #[test]
816    fn op_d_i_then_esc_cancels() {
817        // `di<Esc>` — Esc after OpTextObj transition cancels.
818        let state = op_text_obj(OperatorKind::Delete, 1, true);
819        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
820    }
821
822    #[test]
823    fn op_d_i_then_enter_cancels() {
824        // Non-char key after `di` cancels (mirrors OpFind arm).
825        let state = op_text_obj(OperatorKind::Delete, 1, true);
826        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
827    }
828
829    #[test]
830    fn op_2d_i_w_total_count_2_preserved() {
831        // `2diw`: count1=2, inner_count=0 → total=2. Check count carry-through.
832        // Simulate via AfterOp(count1=2, inner_count=0) then 'i', then 'w'.
833        let state = PendingState::AfterOp {
834            op: OperatorKind::Delete,
835            count1: 2,
836            inner_count: 0,
837        };
838        let Outcome::Wait(obj_state) = step(state, Key::Char('i')) else {
839            panic!("expected Wait(OpTextObj)");
840        };
841        assert_eq!(
842            obj_state,
843            PendingState::OpTextObj {
844                op: OperatorKind::Delete,
845                total_count: 2,
846                inner: true,
847            }
848        );
849        assert_eq!(
850            step(obj_state, Key::Char('w')),
851            Outcome::Commit(EngineCmd::ApplyOpTextObj {
852                op: OperatorKind::Delete,
853                ch: 'w',
854                inner: true,
855                total_count: 2,
856            })
857        );
858    }
859
860    #[test]
861    fn op_d_bare_zero_is_line_start_motion() {
862        // Bare '0' with inner_count=0 → LineStart motion (total=1).
863        let state = after_op(OperatorKind::Delete, 1);
864        assert_eq!(
865            step(state, Key::Char('0')),
866            Outcome::Commit(EngineCmd::ApplyOpMotion {
867                op: OperatorKind::Delete,
868                motion_key: '0',
869                total_count: 1,
870            })
871        );
872    }
873
874    #[test]
875    fn op_d_zero_accumulates_when_inner_count_nonzero() {
876        // d10w: '1' accumulates to inner=1, then '0' accumulates (inner>0) to inner=10.
877        let state = after_op(OperatorKind::Delete, 1);
878        let Outcome::Wait(s2) = step(state, Key::Char('1')) else {
879            panic!("expected Wait");
880        };
881        let Outcome::Wait(s3) = step(s2, Key::Char('0')) else {
882            panic!("expected Wait");
883        };
884        assert_eq!(
885            s3,
886            PendingState::AfterOp {
887                op: OperatorKind::Delete,
888                count1: 1,
889                inner_count: 10,
890            }
891        );
892        assert_eq!(
893            step(s3, Key::Char('w')),
894            Outcome::Commit(EngineCmd::ApplyOpMotion {
895                op: OperatorKind::Delete,
896                motion_key: 'w',
897                total_count: 10,
898            })
899        );
900    }
901
902    // Per-operator round-trip tests.
903
904    #[test]
905    fn op_yank_doubled() {
906        let state = after_op(OperatorKind::Yank, 1);
907        assert_eq!(
908            step(state, Key::Char('y')),
909            Outcome::Commit(EngineCmd::ApplyOpDouble {
910                op: OperatorKind::Yank,
911                total_count: 1,
912            })
913        );
914    }
915
916    #[test]
917    fn op_change_doubled() {
918        let state = after_op(OperatorKind::Change, 1);
919        assert_eq!(
920            step(state, Key::Char('c')),
921            Outcome::Commit(EngineCmd::ApplyOpDouble {
922                op: OperatorKind::Change,
923                total_count: 1,
924            })
925        );
926    }
927
928    #[test]
929    fn op_indent_doubled() {
930        let state = after_op(OperatorKind::Indent, 1);
931        assert_eq!(
932            step(state, Key::Char('>')),
933            Outcome::Commit(EngineCmd::ApplyOpDouble {
934                op: OperatorKind::Indent,
935                total_count: 1,
936            })
937        );
938    }
939
940    #[test]
941    fn op_outdent_doubled() {
942        let state = after_op(OperatorKind::Outdent, 1);
943        assert_eq!(
944            step(state, Key::Char('<')),
945            Outcome::Commit(EngineCmd::ApplyOpDouble {
946                op: OperatorKind::Outdent,
947                total_count: 1,
948            })
949        );
950    }
951
952    #[test]
953    fn op_yank_motion() {
954        let state = after_op(OperatorKind::Yank, 1);
955        assert_eq!(
956            step(state, Key::Char('$')),
957            Outcome::Commit(EngineCmd::ApplyOpMotion {
958                op: OperatorKind::Yank,
959                motion_key: '$',
960                total_count: 1,
961            })
962        );
963    }
964
965    #[test]
966    fn op_change_motion() {
967        let state = after_op(OperatorKind::Change, 1);
968        assert_eq!(
969            step(state, Key::Char('w')),
970            Outcome::Commit(EngineCmd::ApplyOpMotion {
971                op: OperatorKind::Change,
972                motion_key: 'w',
973                total_count: 1,
974            })
975        );
976    }
977
978    #[test]
979    fn op_indent_motion() {
980        let state = after_op(OperatorKind::Indent, 1);
981        assert_eq!(
982            step(state, Key::Char('j')),
983            Outcome::Commit(EngineCmd::ApplyOpMotion {
984                op: OperatorKind::Indent,
985                motion_key: 'j',
986                total_count: 1,
987            })
988        );
989    }
990
991    #[test]
992    fn op_outdent_motion() {
993        let state = after_op(OperatorKind::Outdent, 1);
994        assert_eq!(
995            step(state, Key::Char('k')),
996            Outcome::Commit(EngineCmd::ApplyOpMotion {
997                op: OperatorKind::Outdent,
998                motion_key: 'k',
999                total_count: 1,
1000            })
1001        );
1002    }
1003
1004    // ── OpG reducer unit tests ───────────────────────────────────────────────
1005
1006    fn op_g(op: OperatorKind, total_count: usize) -> PendingState {
1007        PendingState::OpG { op, total_count }
1008    }
1009
1010    #[test]
1011    fn op_d_then_g_then_g_commits_apply_op_g_for_gg() {
1012        // `dgg` → ApplyOpG { Delete, 'g', total_count:1 }
1013        let state = op_g(OperatorKind::Delete, 1);
1014        assert_eq!(
1015            step(state, Key::Char('g')),
1016            Outcome::Commit(EngineCmd::ApplyOpG {
1017                op: OperatorKind::Delete,
1018                ch: 'g',
1019                total_count: 1,
1020            })
1021        );
1022    }
1023
1024    #[test]
1025    fn op_d_then_g_then_e_commits_for_ge() {
1026        // `dge` → ApplyOpG { Delete, 'e', total_count:1 }
1027        let state = op_g(OperatorKind::Delete, 1);
1028        assert_eq!(
1029            step(state, Key::Char('e')),
1030            Outcome::Commit(EngineCmd::ApplyOpG {
1031                op: OperatorKind::Delete,
1032                ch: 'e',
1033                total_count: 1,
1034            })
1035        );
1036    }
1037
1038    #[test]
1039    fn op_d_then_g_then_j_commits_for_gj() {
1040        // `dgj` → ApplyOpG { Delete, 'j', total_count:1 }
1041        let state = op_g(OperatorKind::Delete, 1);
1042        assert_eq!(
1043            step(state, Key::Char('j')),
1044            Outcome::Commit(EngineCmd::ApplyOpG {
1045                op: OperatorKind::Delete,
1046                ch: 'j',
1047                total_count: 1,
1048            })
1049        );
1050    }
1051
1052    #[test]
1053    fn op_2d_3g_g_total_count_6() {
1054        // `2d3gg`: count1=2, inner_count=3 → total=6 folded at AfterOp→OpG.
1055        // Simulate via AfterOp(count1=2, inner_count=3) then 'g', then 'g'.
1056        let state = PendingState::AfterOp {
1057            op: OperatorKind::Delete,
1058            count1: 2,
1059            inner_count: 3,
1060        };
1061        let Outcome::Wait(op_g_state) = step(state, Key::Char('g')) else {
1062            panic!("expected Wait(OpG)");
1063        };
1064        assert_eq!(
1065            op_g_state,
1066            PendingState::OpG {
1067                op: OperatorKind::Delete,
1068                total_count: 6,
1069            }
1070        );
1071        assert_eq!(
1072            step(op_g_state, Key::Char('g')),
1073            Outcome::Commit(EngineCmd::ApplyOpG {
1074                op: OperatorKind::Delete,
1075                ch: 'g',
1076                total_count: 6,
1077            })
1078        );
1079    }
1080
1081    #[test]
1082    fn op_d_g_then_esc_cancels() {
1083        // `dg<Esc>` — Esc after OpG transition cancels.
1084        let state = op_g(OperatorKind::Delete, 1);
1085        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
1086    }
1087
1088    #[test]
1089    fn op_d_g_then_enter_cancels() {
1090        // Non-char key after `dg` cancels (mirrors OpFind / OpTextObj arms).
1091        let state = op_g(OperatorKind::Delete, 1);
1092        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
1093    }
1094
1095    #[test]
1096    fn op_c_then_g_then_g_commits_change_op_g() {
1097        // `cgg` → ApplyOpG { Change, 'g', total_count:1 }
1098        let state = op_g(OperatorKind::Change, 1);
1099        assert_eq!(
1100            step(state, Key::Char('g')),
1101            Outcome::Commit(EngineCmd::ApplyOpG {
1102                op: OperatorKind::Change,
1103                ch: 'g',
1104                total_count: 1,
1105            })
1106        );
1107    }
1108}