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