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 (OpState::Awaiting { .. }, _) => (OpState::Resting, vec![]),
156 }
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use escriba_core::{Mode, Motion};
164 use zenmai::Machine;
165
166 #[test]
167 fn operator_then_motion_composes_apply_operator() {
168 // `d` arms (count 1), `w` composes `dw` once.
169 let (s, fx) =
170 OperatorPending::step(&OpState::Resting, (Action::Operator(Operator::Delete), 1));
171 assert_eq!(
172 s,
173 OpState::Awaiting {
174 op: Operator::Delete,
175 count: 1
176 }
177 );
178 assert!(fx.is_empty(), "the operator key runs nothing, it waits");
179
180 let (s, fx) = OperatorPending::step(&s, (Action::Move(Motion::WordStartNext), 1));
181 assert_eq!(s, OpState::Resting);
182 assert_eq!(
183 fx,
184 vec![(
185 Action::ApplyOperator {
186 op: Operator::Delete,
187 motion: Motion::WordStartNext
188 },
189 1
190 )]
191 );
192 }
193
194 #[test]
195 fn operator_count_multiplies_the_motion() {
196 // `3dw` = delete 3 words: the operator's count (3) flows to the
197 // composed motion as the repetition count.
198 let (s, _) =
199 OperatorPending::step(&OpState::Resting, (Action::Operator(Operator::Delete), 3));
200 assert_eq!(
201 s,
202 OpState::Awaiting {
203 op: Operator::Delete,
204 count: 3
205 }
206 );
207 let (_, fx) = OperatorPending::step(&s, (Action::Move(Motion::WordStartNext), 1));
208 assert_eq!(
209 fx,
210 vec![(
211 Action::ApplyOperator {
212 op: Operator::Delete,
213 motion: Motion::WordStartNext
214 },
215 3
216 )]
217 );
218 }
219
220 #[test]
221 fn operator_and_motion_counts_multiply() {
222 // `3d2w` = delete 3×2 = 6 words.
223 let (s, _) =
224 OperatorPending::step(&OpState::Resting, (Action::Operator(Operator::Delete), 3));
225 let (_, fx) = OperatorPending::step(&s, (Action::Move(Motion::WordStartNext), 2));
226 assert_eq!(fx[0].1, 6, "operator count × motion count");
227 }
228
229 #[test]
230 fn bare_motion_passes_through_carrying_its_count() {
231 // `5j` is not an operated motion — it passes through with count 5.
232 let (s, fx) = OperatorPending::step(&OpState::Resting, (Action::Move(Motion::Down), 5));
233 assert_eq!(s, OpState::Resting);
234 assert_eq!(fx, vec![(Action::Move(Motion::Down), 5)]);
235 }
236
237 #[test]
238 fn esc_cancels_a_pending_operator_and_drops_the_key() {
239 let (s, fx) = OperatorPending::step(
240 &OpState::Awaiting {
241 op: Operator::Change,
242 count: 1,
243 },
244 (Action::ChangeMode(Mode::Normal), 1),
245 );
246 assert_eq!(s, OpState::Resting, "Esc cancels the operator");
247 assert!(fx.is_empty(), "the cancel key is dropped");
248 }
249
250 #[test]
251 fn pending_never_disturbs_operator_state() {
252 // A stray Pending while awaiting keeps the operator armed (a multi-key
253 // motion like `gg` builds in the runtime's sequence buffer first).
254 let (s, fx) = OperatorPending::step(
255 &OpState::Awaiting {
256 op: Operator::Yank,
257 count: 1,
258 },
259 (Action::Pending, 1),
260 );
261 assert_eq!(
262 s,
263 OpState::Awaiting {
264 op: Operator::Yank,
265 count: 1
266 }
267 );
268 assert!(fx.is_empty());
269 }
270
271 #[test]
272 fn inert_on_a_doubled_operator_dd_deferred() {
273 // `dd` (linewise) is deferred — a second operator cancels for now.
274 let (s, fx) = OperatorPending::step(
275 &OpState::Awaiting {
276 op: Operator::Delete,
277 count: 1,
278 },
279 (Action::Operator(Operator::Delete), 1),
280 );
281 assert_eq!(s, OpState::Resting);
282 assert!(fx.is_empty());
283 }
284}