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    // 2c–2e sub-state variants land later.
47}
48
49/// One step of the reducer.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum Outcome {
52    /// Need more keys — keep accumulating with new state.
53    Wait(PendingState),
54    /// Run this engine command, then clear pending.
55    Commit(crate::cmd::EngineCmd),
56    /// Cancel pending (Esc, invalid char, etc.). No engine call.
57    Cancel,
58    /// Pending state didn't consume this key — host should route it
59    /// normally (e.g. modifier-only key). Pending state stays alive.
60    Forward,
61}
62
63/// `Key` is intentionally minimal — hjkl-vim should not depend on
64/// crossterm. Hosts translate their native keys into this shape.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Key {
67    Char(char),
68    Esc,
69    Enter,
70    Backspace,
71    Tab,
72    // Add more variants only as later chunks require them.
73}
74
75pub fn step(state: PendingState, key: Key) -> Outcome {
76    match state {
77        PendingState::Replace { count } => match key {
78            Key::Esc => Outcome::Cancel,
79            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::ReplaceChar { ch, count }),
80            Key::Enter => Outcome::Commit(crate::cmd::EngineCmd::ReplaceChar { ch: '\n', count }),
81            _ => Outcome::Cancel,
82        },
83        PendingState::Find {
84            count,
85            forward,
86            till,
87        } => match key {
88            Key::Esc => Outcome::Cancel,
89            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::FindChar {
90                ch,
91                forward,
92                till,
93                count,
94            }),
95            // Any non-char key cancels (vim cancels f<non-char>).
96            _ => Outcome::Cancel,
97        },
98        PendingState::AfterG { count } => match key {
99            Key::Esc => Outcome::Cancel,
100            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::AfterGChord { ch, count }),
101            // Any non-char key cancels (mirrors Find arm).
102            _ => Outcome::Cancel,
103        },
104        PendingState::AfterZ { count } => match key {
105            Key::Esc => Outcome::Cancel,
106            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::AfterZChord { ch, count }),
107            // Any non-char key cancels (mirrors AfterG arm).
108            _ => Outcome::Cancel,
109        },
110        PendingState::AfterOp {
111            op,
112            count1,
113            inner_count,
114        } => match key {
115            Key::Esc => Outcome::Cancel,
116            Key::Char(d @ '0'..='9') => {
117                // Vim quirk: bare `0` with inner_count==0 is LineStart motion.
118                if d == '0' && inner_count == 0 {
119                    // Treat as motion key — engine will parse '0' as LineStart.
120                    let total = count1.max(1);
121                    Outcome::Commit(crate::cmd::EngineCmd::ApplyOpMotion {
122                        op,
123                        motion_key: '0',
124                        total_count: total,
125                    })
126                } else {
127                    let new_inner = inner_count
128                        .saturating_mul(10)
129                        .saturating_add(d as usize - '0' as usize);
130                    Outcome::Wait(PendingState::AfterOp {
131                        op,
132                        count1,
133                        inner_count: new_inner,
134                    })
135                }
136            }
137            Key::Char(ch) => {
138                let total = count1.max(1) * inner_count.max(1);
139                // Doubled letter → line op (dd/yy/cc/>>/<<).
140                if ch == op.double_char() {
141                    Outcome::Commit(crate::cmd::EngineCmd::ApplyOpDouble {
142                        op,
143                        total_count: total,
144                    })
145                // Text object: `i` → inner, `a` → outer.
146                } else if ch == 'i' {
147                    Outcome::Commit(crate::cmd::EngineCmd::EnterOpTextObj {
148                        op,
149                        count1,
150                        inner: true,
151                    })
152                } else if ch == 'a' {
153                    Outcome::Commit(crate::cmd::EngineCmd::EnterOpTextObj {
154                        op,
155                        count1,
156                        inner: false,
157                    })
158                // g-chord sub-pending (dgg, dgU, etc.).
159                } else if ch == 'g' {
160                    Outcome::Commit(crate::cmd::EngineCmd::EnterOpG { op, count1 })
161                // Find sub-pending (df/dF/dt/dT).
162                } else if ch == 'f' {
163                    Outcome::Commit(crate::cmd::EngineCmd::EnterOpFind {
164                        op,
165                        count1,
166                        forward: true,
167                        till: false,
168                    })
169                } else if ch == 'F' {
170                    Outcome::Commit(crate::cmd::EngineCmd::EnterOpFind {
171                        op,
172                        count1,
173                        forward: false,
174                        till: false,
175                    })
176                } else if ch == 't' {
177                    Outcome::Commit(crate::cmd::EngineCmd::EnterOpFind {
178                        op,
179                        count1,
180                        forward: true,
181                        till: true,
182                    })
183                } else if ch == 'T' {
184                    Outcome::Commit(crate::cmd::EngineCmd::EnterOpFind {
185                        op,
186                        count1,
187                        forward: false,
188                        till: true,
189                    })
190                } else {
191                    // All other chars: treat as motion key and let the engine
192                    // parse it via parse_motion. Unknown keys no-op in the engine.
193                    Outcome::Commit(crate::cmd::EngineCmd::ApplyOpMotion {
194                        op,
195                        motion_key: ch,
196                        total_count: total,
197                    })
198                }
199            }
200            // Non-char, non-Esc → cancel (mirrors Find/AfterG arms).
201            _ => Outcome::Cancel,
202        },
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::cmd::EngineCmd;
210    use crate::operator::OperatorKind;
211
212    // ── AfterG reducer unit tests ────────────────────────────────────────────
213
214    #[test]
215    fn after_g_gg_commits() {
216        let state = PendingState::AfterG { count: 1 };
217        assert_eq!(
218            step(state, Key::Char('g')),
219            Outcome::Commit(EngineCmd::AfterGChord { ch: 'g', count: 1 })
220        );
221    }
222
223    #[test]
224    fn after_g_gv_commits() {
225        let state = PendingState::AfterG { count: 1 };
226        assert_eq!(
227            step(state, Key::Char('v')),
228            Outcome::Commit(EngineCmd::AfterGChord { ch: 'v', count: 1 })
229        );
230    }
231
232    #[test]
233    fn after_g_gu_operator_commits() {
234        // gU still produces AfterGChord; the engine handles the Pending::Op transition.
235        let state = PendingState::AfterG { count: 1 };
236        assert_eq!(
237            step(state, Key::Char('U')),
238            Outcome::Commit(EngineCmd::AfterGChord { ch: 'U', count: 1 })
239        );
240    }
241
242    #[test]
243    fn after_g_gi_commits() {
244        let state = PendingState::AfterG { count: 1 };
245        assert_eq!(
246            step(state, Key::Char('i')),
247            Outcome::Commit(EngineCmd::AfterGChord { ch: 'i', count: 1 })
248        );
249    }
250
251    #[test]
252    fn after_g_esc_cancels() {
253        let state = PendingState::AfterG { count: 1 };
254        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
255    }
256
257    #[test]
258    fn after_g_count_carry_through() {
259        // 5gg enters with count=5 — AfterGChord carries it through.
260        let state = PendingState::AfterG { count: 5 };
261        assert_eq!(
262            step(state, Key::Char('g')),
263            Outcome::Commit(EngineCmd::AfterGChord { ch: 'g', count: 5 })
264        );
265    }
266
267    #[test]
268    fn after_g_non_char_cancels() {
269        // Non-char, non-Esc key (e.g. Enter) cancels.
270        let state = PendingState::AfterG { count: 1 };
271        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
272    }
273
274    // ── AfterZ reducer unit tests ────────────────────────────────────────────
275
276    #[test]
277    fn after_z_zz_commits() {
278        let state = PendingState::AfterZ { count: 1 };
279        assert_eq!(
280            step(state, Key::Char('z')),
281            Outcome::Commit(EngineCmd::AfterZChord { ch: 'z', count: 1 })
282        );
283    }
284
285    #[test]
286    fn after_z_zf_commits() {
287        let state = PendingState::AfterZ { count: 1 };
288        assert_eq!(
289            step(state, Key::Char('f')),
290            Outcome::Commit(EngineCmd::AfterZChord { ch: 'f', count: 1 })
291        );
292    }
293
294    #[test]
295    fn after_z_esc_cancels() {
296        let state = PendingState::AfterZ { count: 1 };
297        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
298    }
299
300    #[test]
301    fn after_z_count_carry_through() {
302        // 3zz enters with count=3 — AfterZChord carries it through.
303        let state = PendingState::AfterZ { count: 3 };
304        assert_eq!(
305            step(state, Key::Char('z')),
306            Outcome::Commit(EngineCmd::AfterZChord { ch: 'z', count: 3 })
307        );
308    }
309
310    #[test]
311    fn after_z_non_char_cancels() {
312        // Non-char, non-Esc key (e.g. Enter) cancels.
313        let state = PendingState::AfterZ { count: 1 };
314        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
315    }
316
317    // ── AfterOp reducer unit tests ───────────────────────────────────────────
318
319    fn after_op(op: OperatorKind, count1: usize) -> PendingState {
320        PendingState::AfterOp {
321            op,
322            count1,
323            inner_count: 0,
324        }
325    }
326
327    #[test]
328    fn op_d_then_w_commits_motion() {
329        let state = after_op(OperatorKind::Delete, 1);
330        assert_eq!(
331            step(state, Key::Char('w')),
332            Outcome::Commit(EngineCmd::ApplyOpMotion {
333                op: OperatorKind::Delete,
334                motion_key: 'w',
335                total_count: 1,
336            })
337        );
338    }
339
340    #[test]
341    fn op_d_then_d_commits_double() {
342        let state = after_op(OperatorKind::Delete, 1);
343        assert_eq!(
344            step(state, Key::Char('d')),
345            Outcome::Commit(EngineCmd::ApplyOpDouble {
346                op: OperatorKind::Delete,
347                total_count: 1,
348            })
349        );
350    }
351
352    #[test]
353    fn op_d_inner_count_d3w_commits_motion_with_count_3() {
354        // d3w: count1=1, inner_count accumulates to 3, total=3.
355        let state = after_op(OperatorKind::Delete, 1);
356        // Type '3'.
357        let Outcome::Wait(state2) = step(state, Key::Char('3')) else {
358            panic!("expected Wait");
359        };
360        assert_eq!(
361            state2,
362            PendingState::AfterOp {
363                op: OperatorKind::Delete,
364                count1: 1,
365                inner_count: 3
366            }
367        );
368        // Type 'w'.
369        assert_eq!(
370            step(state2, Key::Char('w')),
371            Outcome::Commit(EngineCmd::ApplyOpMotion {
372                op: OperatorKind::Delete,
373                motion_key: 'w',
374                total_count: 3,
375            })
376        );
377    }
378
379    #[test]
380    fn op_2d_d_commits_double_with_count_2() {
381        // 2dd: count1=2, inner_count=0, doubled → total=2.
382        let state = after_op(OperatorKind::Delete, 2);
383        assert_eq!(
384            step(state, Key::Char('d')),
385            Outcome::Commit(EngineCmd::ApplyOpDouble {
386                op: OperatorKind::Delete,
387                total_count: 2,
388            })
389        );
390    }
391
392    #[test]
393    fn op_2d_3w_commits_motion_with_total_6() {
394        // 2d3w: count1=2, inner=3, total=6.
395        let state = after_op(OperatorKind::Delete, 2);
396        let Outcome::Wait(state2) = step(state, Key::Char('3')) else {
397            panic!("expected Wait");
398        };
399        assert_eq!(
400            step(state2, Key::Char('w')),
401            Outcome::Commit(EngineCmd::ApplyOpMotion {
402                op: OperatorKind::Delete,
403                motion_key: 'w',
404                total_count: 6,
405            })
406        );
407    }
408
409    #[test]
410    fn op_d_then_i_emits_enter_op_text_obj_inner_true() {
411        let state = after_op(OperatorKind::Delete, 1);
412        assert_eq!(
413            step(state, Key::Char('i')),
414            Outcome::Commit(EngineCmd::EnterOpTextObj {
415                op: OperatorKind::Delete,
416                count1: 1,
417                inner: true,
418            })
419        );
420    }
421
422    #[test]
423    fn op_d_then_a_emits_enter_op_text_obj_inner_false() {
424        let state = after_op(OperatorKind::Delete, 1);
425        assert_eq!(
426            step(state, Key::Char('a')),
427            Outcome::Commit(EngineCmd::EnterOpTextObj {
428                op: OperatorKind::Delete,
429                count1: 1,
430                inner: false,
431            })
432        );
433    }
434
435    #[test]
436    fn op_d_then_g_emits_enter_op_g() {
437        let state = after_op(OperatorKind::Delete, 1);
438        assert_eq!(
439            step(state, Key::Char('g')),
440            Outcome::Commit(EngineCmd::EnterOpG {
441                op: OperatorKind::Delete,
442                count1: 1,
443            })
444        );
445    }
446
447    #[test]
448    fn op_d_then_f_emits_enter_op_find_forward_not_till() {
449        let state = after_op(OperatorKind::Delete, 1);
450        assert_eq!(
451            step(state, Key::Char('f')),
452            Outcome::Commit(EngineCmd::EnterOpFind {
453                op: OperatorKind::Delete,
454                count1: 1,
455                forward: true,
456                till: false,
457            })
458        );
459    }
460
461    #[test]
462    fn op_d_then_cap_f_emits_enter_op_find_backward_not_till() {
463        let state = after_op(OperatorKind::Delete, 1);
464        assert_eq!(
465            step(state, Key::Char('F')),
466            Outcome::Commit(EngineCmd::EnterOpFind {
467                op: OperatorKind::Delete,
468                count1: 1,
469                forward: false,
470                till: false,
471            })
472        );
473    }
474
475    #[test]
476    fn op_d_then_t_emits_enter_op_find_forward_till() {
477        let state = after_op(OperatorKind::Delete, 1);
478        assert_eq!(
479            step(state, Key::Char('t')),
480            Outcome::Commit(EngineCmd::EnterOpFind {
481                op: OperatorKind::Delete,
482                count1: 1,
483                forward: true,
484                till: true,
485            })
486        );
487    }
488
489    #[test]
490    fn op_d_then_cap_t_emits_enter_op_find_backward_till() {
491        let state = after_op(OperatorKind::Delete, 1);
492        assert_eq!(
493            step(state, Key::Char('T')),
494            Outcome::Commit(EngineCmd::EnterOpFind {
495                op: OperatorKind::Delete,
496                count1: 1,
497                forward: false,
498                till: true,
499            })
500        );
501    }
502
503    #[test]
504    fn op_d_then_esc_cancels() {
505        let state = after_op(OperatorKind::Delete, 1);
506        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
507    }
508
509    #[test]
510    fn op_d_non_char_cancels() {
511        let state = after_op(OperatorKind::Delete, 1);
512        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
513    }
514
515    #[test]
516    fn op_d_bare_zero_is_line_start_motion() {
517        // Bare '0' with inner_count=0 → LineStart motion (total=1).
518        let state = after_op(OperatorKind::Delete, 1);
519        assert_eq!(
520            step(state, Key::Char('0')),
521            Outcome::Commit(EngineCmd::ApplyOpMotion {
522                op: OperatorKind::Delete,
523                motion_key: '0',
524                total_count: 1,
525            })
526        );
527    }
528
529    #[test]
530    fn op_d_zero_accumulates_when_inner_count_nonzero() {
531        // d10w: '1' accumulates to inner=1, then '0' accumulates (inner>0) to inner=10.
532        let state = after_op(OperatorKind::Delete, 1);
533        let Outcome::Wait(s2) = step(state, Key::Char('1')) else {
534            panic!("expected Wait");
535        };
536        let Outcome::Wait(s3) = step(s2, Key::Char('0')) else {
537            panic!("expected Wait");
538        };
539        assert_eq!(
540            s3,
541            PendingState::AfterOp {
542                op: OperatorKind::Delete,
543                count1: 1,
544                inner_count: 10,
545            }
546        );
547        assert_eq!(
548            step(s3, Key::Char('w')),
549            Outcome::Commit(EngineCmd::ApplyOpMotion {
550                op: OperatorKind::Delete,
551                motion_key: 'w',
552                total_count: 10,
553            })
554        );
555    }
556
557    // Per-operator round-trip tests.
558
559    #[test]
560    fn op_yank_doubled() {
561        let state = after_op(OperatorKind::Yank, 1);
562        assert_eq!(
563            step(state, Key::Char('y')),
564            Outcome::Commit(EngineCmd::ApplyOpDouble {
565                op: OperatorKind::Yank,
566                total_count: 1,
567            })
568        );
569    }
570
571    #[test]
572    fn op_change_doubled() {
573        let state = after_op(OperatorKind::Change, 1);
574        assert_eq!(
575            step(state, Key::Char('c')),
576            Outcome::Commit(EngineCmd::ApplyOpDouble {
577                op: OperatorKind::Change,
578                total_count: 1,
579            })
580        );
581    }
582
583    #[test]
584    fn op_indent_doubled() {
585        let state = after_op(OperatorKind::Indent, 1);
586        assert_eq!(
587            step(state, Key::Char('>')),
588            Outcome::Commit(EngineCmd::ApplyOpDouble {
589                op: OperatorKind::Indent,
590                total_count: 1,
591            })
592        );
593    }
594
595    #[test]
596    fn op_outdent_doubled() {
597        let state = after_op(OperatorKind::Outdent, 1);
598        assert_eq!(
599            step(state, Key::Char('<')),
600            Outcome::Commit(EngineCmd::ApplyOpDouble {
601                op: OperatorKind::Outdent,
602                total_count: 1,
603            })
604        );
605    }
606
607    #[test]
608    fn op_yank_motion() {
609        let state = after_op(OperatorKind::Yank, 1);
610        assert_eq!(
611            step(state, Key::Char('$')),
612            Outcome::Commit(EngineCmd::ApplyOpMotion {
613                op: OperatorKind::Yank,
614                motion_key: '$',
615                total_count: 1,
616            })
617        );
618    }
619
620    #[test]
621    fn op_change_motion() {
622        let state = after_op(OperatorKind::Change, 1);
623        assert_eq!(
624            step(state, Key::Char('w')),
625            Outcome::Commit(EngineCmd::ApplyOpMotion {
626                op: OperatorKind::Change,
627                motion_key: 'w',
628                total_count: 1,
629            })
630        );
631    }
632
633    #[test]
634    fn op_indent_motion() {
635        let state = after_op(OperatorKind::Indent, 1);
636        assert_eq!(
637            step(state, Key::Char('j')),
638            Outcome::Commit(EngineCmd::ApplyOpMotion {
639                op: OperatorKind::Indent,
640                motion_key: 'j',
641                total_count: 1,
642            })
643        );
644    }
645
646    #[test]
647    fn op_outdent_motion() {
648        let state = after_op(OperatorKind::Outdent, 1);
649        assert_eq!(
650            step(state, Key::Char('k')),
651            Outcome::Commit(EngineCmd::ApplyOpMotion {
652                op: OperatorKind::Outdent,
653                motion_key: 'k',
654                total_count: 1,
655            })
656        );
657    }
658}