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