Skip to main content

escriba_runtime/
operator_pending.rs

1//! The operator-pending FSM — vim's `{count}{operator}{count}{motion}`
2//! key composition.
3//!
4//! After the `d`/`c`/`y` key ([`Action::Operator`]) the editor *waits* for a
5//! motion; the next motion key composes into an [`Action::ApplyOperator`] the
6//! runtime executes (the engine built in `apply_operator`). This is a pure
7//! `(State, Event) -> (State, effects)` machine, so it **stands on the fleet
8//! `zenmai` primitive** (the same Mealy-machine-with-effects abstraction
9//! `bolso-core` and `gaveta-client-core::Escort` use) rather than re-rolling a
10//! bespoke `Option<Operator>` + scattered `if let`s in the dispatch path.
11//!
12//! **Counts compose here, not in a naive repeat loop.** The `Event` and
13//! `Effect` are `(Action, count)`: the machine consumes the resolved action +
14//! its count off the keymap, and emits the action(s) the runtime should run +
15//! *how many times*. A bare motion (`5j`) passes through carrying its count; an
16//! operated motion multiplies the two counts (`3d2w` = delete `3 × 2 = 6`
17//! words), because vim's operator count and motion count multiply.
18
19use escriba_core::{Action, Operator};
20
21/// Operator-pending state. `Resting` = normal; `Awaiting { op, count }` = an
22/// operator key was pressed (with `count`, default 1) and the next motion will
23/// be operated over.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum OpState {
26    #[default]
27    Resting,
28    Awaiting {
29        op: Operator,
30        count: u32,
31    },
32    /// `d/` — an operator is armed AND a search prompt is open.
33    ///
34    /// Without this state the prompt's own keys (the typed pattern, Backspace,
35    /// history) hit the "anything else cancels" arm and the operator
36    /// evaporated on the very first character, so `d/foo<CR>` silently did
37    /// nothing. The operator has to survive an arbitrary number of keystrokes
38    /// here, which is exactly why it needs a state rather than a guard.
39    AwaitingSearch {
40        op: Operator,
41        count: u32,
42    },
43}
44
45/// The zenmai machine. A ZST marker; the reducer is [`Self::step`]. The
46/// `(Action, u32)` event/effect pair is the action and its repetition count.
47pub struct OperatorPending;
48
49impl zenmai::Machine for OperatorPending {
50    type State = OpState;
51    /// The resolved action and its count (from the keymap's `CountedAction`).
52    type Event = (Action, u32);
53    /// The action the runtime should execute and how many times. Empty = the
54    /// key was consumed (an operator began, or a pending operator was cancelled).
55    type Effect = (Action, u32);
56
57    fn step(state: &OpState, event: (Action, u32)) -> (OpState, Vec<(Action, u32)>) {
58        let (action, count) = event;
59        match (state, action) {
60            // A stray `Pending` (mid-sequence key) never disturbs operator
61            // state and runs nothing — the runtime's own sequence buffer owns it.
62            (s, Action::Pending) => (*s, vec![]),
63
64            // Resting: an operator key arms the machine (capturing its count);
65            // everything else passes straight through carrying its own count.
66            (OpState::Resting, Action::Operator(op)) => (OpState::Awaiting { op, count }, vec![]),
67            (OpState::Resting, other) => (OpState::Resting, vec![(other, count)]),
68
69            // Awaiting a motion: the motion composes into ApplyOperator, applied
70            // `op_count × motion_count` times (the two vim counts multiply).
71            (
72                OpState::Awaiting {
73                    op,
74                    count: op_count,
75                },
76                Action::Move(motion),
77            ) => (
78                OpState::Resting,
79                vec![(
80                    Action::ApplyOperator { op: *op, motion },
81                    op_count.saturating_mul(count).max(1),
82                )],
83            ),
84
85            // Awaiting + a text OBJECT composes over its extent, not over a
86            // range ending at the cursor. `dgn` deletes the match wherever it
87            // is; a motion-shaped composition would delete up to it instead.
88            (
89                OpState::Awaiting {
90                    op,
91                    count: op_count,
92                },
93                Action::TextObject(object),
94            ) => (
95                OpState::Resting,
96                vec![(
97                    Action::ApplyOperatorObject { op: *op, object },
98                    op_count.saturating_mul(count).max(1),
99                )],
100            ),
101
102            // Awaiting + `/` or `?`: open the prompt and STAY ARMED. This arm
103            // is the whole fix — it used to fall into the catch-all below,
104            // which disarmed the operator and dropped the key.
105            (
106                OpState::Awaiting {
107                    op,
108                    count: op_count,
109                },
110                Action::SearchOpen(dir),
111            ) => (
112                OpState::AwaitingSearch {
113                    op: *op,
114                    count: *op_count,
115                },
116                vec![(Action::SearchOpen(dir), 1)],
117            ),
118
119            // Prompt editing passes through untouched while the operator
120            // waits. Classified by `Action::edits_prompt`, which is TOTAL over
121            // `Action`, rather than by a list here — the list is how this
122            // broke once already: five prompt actions were added later and
123            // none reached it, so `←` or `<C-g>` midway through `d/foo`
124            // silently disarmed the operator.
125            (OpState::AwaitingSearch { .. }, a) if a.edits_prompt() => (*state, vec![(a, 1)]),
126
127            // `<CR>` resolves the operand. Emitted as ONE action rather than
128            // "commit, then operate": committing MOVES the cursor, which would
129            // destroy the operator's start point before the operator ran.
130            (
131                OpState::AwaitingSearch {
132                    op,
133                    count: op_count,
134                },
135                Action::SubmitCommand,
136            ) => (
137                OpState::Resting,
138                vec![(Action::SearchSubmitOperated { op: *op }, *op_count)],
139            ),
140
141            // Esc disarms both the prompt and the operator.
142            (OpState::AwaitingSearch { .. }, Action::ChangeMode(m)) => {
143                (OpState::Resting, vec![(Action::ChangeMode(m), 1)])
144            }
145
146            // Any other key while a search operand is being typed disarms the
147            // operator but still runs the key — dropping it silently is what
148            // made `d/` feel broken.
149            (OpState::AwaitingSearch { .. }, other) => (OpState::Resting, vec![(other, count)]),
150
151            // Awaiting + anything-not-a-motion cancels the operator and drops
152            // the key (vim: `d` then a non-motion does nothing). Covers Esc
153            // (ChangeMode), operator-doubling `dd` (linewise — deferred), and
154            // any other key.
155            // A DOUBLED operator is linewise: `dd` / `cc` / `yy`. vim treats
156            // the repeat as "this whole line", which is why it composes to a
157            // `Line` OBJECT rather than to a motion — no cursor-to-target
158            // range expresses "this line and its terminator" without
159            // special-casing the last line, and the object resolver owns
160            // that case.
161            //
162            // Counts multiply as everywhere else, so `2dd` deletes two lines
163            // by applying the object twice.
164            (
165                OpState::Awaiting {
166                    op,
167                    count: op_count,
168                },
169                Action::Operator(op2),
170            ) if *op == op2 => (
171                OpState::Resting,
172                vec![(
173                    Action::ApplyOperatorObject {
174                        op: *op,
175                        object: escriba_core::TextObject::Line,
176                    },
177                    op_count.saturating_mul(count).max(1),
178                )],
179            ),
180
181            // A DIFFERENT operator replaces the pending one rather than
182            // cancelling both — `dc` is a typo for `cc`, and vim re-arms.
183            (OpState::Awaiting { .. }, Action::Operator(op2)) => {
184                (OpState::Awaiting { op: op2, count }, vec![])
185            }
186
187            (OpState::Awaiting { .. }, _) => (OpState::Resting, vec![]),
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use escriba_core::{Mode, Motion};
196    use zenmai::Machine;
197
198    #[test]
199    fn operator_then_motion_composes_apply_operator() {
200        // `d` arms (count 1), `w` composes `dw` once.
201        let (s, fx) =
202            OperatorPending::step(&OpState::Resting, (Action::Operator(Operator::Delete), 1));
203        assert_eq!(
204            s,
205            OpState::Awaiting {
206                op: Operator::Delete,
207                count: 1
208            }
209        );
210        assert!(fx.is_empty(), "the operator key runs nothing, it waits");
211
212        let (s, fx) = OperatorPending::step(&s, (Action::Move(Motion::WordStartNext), 1));
213        assert_eq!(s, OpState::Resting);
214        assert_eq!(
215            fx,
216            vec![(
217                Action::ApplyOperator {
218                    op: Operator::Delete,
219                    motion: Motion::WordStartNext
220                },
221                1
222            )]
223        );
224    }
225
226    #[test]
227    fn operator_count_multiplies_the_motion() {
228        // `3dw` = delete 3 words: the operator's count (3) flows to the
229        // composed motion as the repetition count.
230        let (s, _) =
231            OperatorPending::step(&OpState::Resting, (Action::Operator(Operator::Delete), 3));
232        assert_eq!(
233            s,
234            OpState::Awaiting {
235                op: Operator::Delete,
236                count: 3
237            }
238        );
239        let (_, fx) = OperatorPending::step(&s, (Action::Move(Motion::WordStartNext), 1));
240        assert_eq!(
241            fx,
242            vec![(
243                Action::ApplyOperator {
244                    op: Operator::Delete,
245                    motion: Motion::WordStartNext
246                },
247                3
248            )]
249        );
250    }
251
252    #[test]
253    fn operator_and_motion_counts_multiply() {
254        // `3d2w` = delete 3×2 = 6 words.
255        let (s, _) =
256            OperatorPending::step(&OpState::Resting, (Action::Operator(Operator::Delete), 3));
257        let (_, fx) = OperatorPending::step(&s, (Action::Move(Motion::WordStartNext), 2));
258        assert_eq!(fx[0].1, 6, "operator count × motion count");
259    }
260
261    #[test]
262    fn bare_motion_passes_through_carrying_its_count() {
263        // `5j` is not an operated motion — it passes through with count 5.
264        let (s, fx) = OperatorPending::step(&OpState::Resting, (Action::Move(Motion::Down), 5));
265        assert_eq!(s, OpState::Resting);
266        assert_eq!(fx, vec![(Action::Move(Motion::Down), 5)]);
267    }
268
269    #[test]
270    fn esc_cancels_a_pending_operator_and_drops_the_key() {
271        let (s, fx) = OperatorPending::step(
272            &OpState::Awaiting {
273                op: Operator::Change,
274                count: 1,
275            },
276            (Action::ChangeMode(Mode::Normal), 1),
277        );
278        assert_eq!(s, OpState::Resting, "Esc cancels the operator");
279        assert!(fx.is_empty(), "the cancel key is dropped");
280    }
281
282    #[test]
283    fn pending_never_disturbs_operator_state() {
284        // A stray Pending while awaiting keeps the operator armed (a multi-key
285        // motion like `gg` builds in the runtime's sequence buffer first).
286        let (s, fx) = OperatorPending::step(
287            &OpState::Awaiting {
288                op: Operator::Yank,
289                count: 1,
290            },
291            (Action::Pending, 1),
292        );
293        assert_eq!(
294            s,
295            OpState::Awaiting {
296                op: Operator::Yank,
297                count: 1
298            }
299        );
300        assert!(fx.is_empty());
301    }
302
303    #[test]
304    fn a_doubled_operator_is_linewise() {
305        // `dd`. This test used to assert the opposite — that a second
306        // operator CANCELLED — which was an accurate record of a deferral,
307        // not a rule. Composing to a `Line` object is the rule.
308        let (s, fx) = OperatorPending::step(
309            &OpState::Awaiting {
310                op: Operator::Delete,
311                count: 1,
312            },
313            (Action::Operator(Operator::Delete), 1),
314        );
315        assert_eq!(s, OpState::Resting);
316        assert_eq!(
317            fx,
318            vec![(
319                Action::ApplyOperatorObject {
320                    op: Operator::Delete,
321                    object: escriba_core::TextObject::Line
322                },
323                1
324            )]
325        );
326    }
327
328    #[test]
329    fn a_doubled_operator_multiplies_its_counts() {
330        // `2dd` and `d2d` both mean two lines, and `2d2d` means four —
331        // the same multiplication every other composition uses.
332        let (_, fx) = OperatorPending::step(
333            &OpState::Awaiting {
334                op: Operator::Delete,
335                count: 2,
336            },
337            (Action::Operator(Operator::Delete), 2),
338        );
339        assert_eq!(fx.first().map(|(_, n)| *n), Some(4));
340    }
341
342    #[test]
343    fn a_different_operator_re_arms_rather_than_cancelling() {
344        // `dc` is a typo for `cc`; vim leaves the machine armed on the NEW
345        // operator rather than dropping both keys on the floor.
346        let (s, fx) = OperatorPending::step(
347            &OpState::Awaiting {
348                op: Operator::Delete,
349                count: 1,
350            },
351            (Action::Operator(Operator::Change), 1),
352        );
353        assert_eq!(
354            s,
355            OpState::Awaiting {
356                op: Operator::Change,
357                count: 1
358            }
359        );
360        assert!(fx.is_empty(), "re-arming runs nothing yet");
361    }
362}