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