escriba_keymap/pipeline.rs
1//! The vim KEY LAYER as a pure, host-agnostic pipeline.
2//!
3//! ## What this is
4//!
5//! Everything between "a key arrived" and "these actions should run": the
6//! operand captures (`di(`, `fx`, `` `a ``, `rZ`), multi-key sequence
7//! resolution (`gg`, `<leader>ff`), count accumulation (`5j`, `3d2w`), and the
8//! operator-pending machine (`d` + motion → [`Action::ApplyOperator`]).
9//!
10//! It lived inside `escriba-runtime`'s `EditorState::on_key` until 2026-09-26,
11//! where it was reachable only by an editor. Every app that wants vim keys —
12//! the `arnes` agent TUI, the `frost` shell — would have had to re-derive it,
13//! and each re-derivation is a new place for `zt`, ``d`a`` or `3f.` to break.
14//! One implementation, here; `escriba-runtime` is now a host of it like any
15//! other.
16//!
17//! ## What it is NOT
18//!
19//! The pipeline never touches text, cursors, buffers, registers or marks. It
20//! turns keys into [`Action`]s; executing them is the host's job. Mode CHANGES
21//! are likewise returned as actions (`ChangeMode`, `EnterInsert`, …) and the
22//! host applies them to its [`ModalState`] — the pipeline only READS the mode
23//! (to decide whether a key is an operand, a sequence step or literal text)
24//! and writes the COUNT prefix, which lives in `ModalState` by design.
25//!
26//! ## Two layers, one implementation
27//!
28//! - [`KeyPipeline::resolve_key`] — the key layer: captures, sequences,
29//! counts, keymap lookup. Yields `(Action, count)` units that have NOT yet
30//! passed the operator machine.
31//! - [`KeyPipeline::compose`] — the operator machine, one unit at a time.
32//! - [`KeyPipeline::feed`] — `resolve_key` then `compose` on every unit: the
33//! one-call surface most hosts want.
34//!
35//! The split exists because the operator machine is ALSO fed by actions that
36//! never came from a key (an editor's splash menu, a picker choice, a lisp
37//! effect), and because a host may need to veto a unit BEFORE the machine sees
38//! it — escriba refuses to submit an uncompilable search pattern while `d/` is
39//! armed, and the machine, being a pure `(State, Event) -> (State, effects)`
40//! reducer, cannot observe that. A host with no such concern calls `feed`.
41
42use escriba_core::{Action, Mode, Motion, TextObject};
43use escriba_mode::{ModalState, OpState, OperatorPending};
44
45use crate::{Key, Keymap};
46
47/// A character search: which character, which direction, and whether it stops
48/// ON it (`f`/`F`) or just BEFORE it (`t`/`T`).
49///
50/// The same value serves the pending operand and the `;`/`,` memory
51/// ([`KeyPipeline::last_find`]), so the thing repeated is the thing that ran.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct FindSpec {
54 pub ch: char,
55 pub backward: bool,
56 pub till: bool,
57}
58
59/// What the next keystroke means after `m`, `` ` `` or `'`.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61enum MarkKey {
62 /// `m{a-z}` — set.
63 Set,
64 /// `` `{a-z} `` — jump to the exact position.
65 GotoExact,
66 /// `'{a-z}` — jump to the line's first non-blank.
67 GotoLine,
68}
69
70/// What an operand capture did with a key.
71enum Claim {
72 /// The key was swallowed: it armed a capture, or cancelled one.
73 Consumed,
74 /// The key completed a gesture. `times` is meaningful only for a
75 /// [`OperandCount::SelfCounted`] capture.
76 Compose { action: Action, times: u32 },
77}
78
79/// How a captured operand's composed action reaches the operator machine.
80#[derive(Clone, Copy, PartialEq, Eq, Debug)]
81enum OperandCount {
82 /// The capture computed its own repeats; emit the action that many times
83 /// at count 1. Only the object path does this (`2diw` is the OPERATOR's
84 /// count, read off the armed machine).
85 SelfCounted,
86 /// Drain the pending count and emit one unit carrying it, so `3fx` /
87 /// `3ra` / ``3`a`` repeat on the one path every other motion uses.
88 Drained,
89}
90
91/// One step of the operand-capture chain.
92struct OperandCapture {
93 /// Stable label — what [`operand_capture_order`] reports.
94 name: &'static str,
95 claim: fn(&mut KeyPipeline, &ModalState, Key) -> Option<Claim>,
96 count: OperandCount,
97}
98
99/// **The operand-capture chain, in the order that matters.**
100///
101/// Every adjacency is a dependency with a named failure:
102///
103/// 1. **mark before object** — the object path claims `i`/`a` whenever an
104/// operator is armed, and a mark LETTER can be either, so ``d`a`` lost its
105/// `a` to it. They do not fight over the FIRST key (the mark path arms only
106/// while `pending_object` is clear, so `di'` still reaches the object
107/// path); they fight over the SECOND, and the gesture already half-typed
108/// must win.
109/// 2. **object before find** — `di(` must not read as `d`, then `i` (insert),
110/// then a literal `(`.
111/// 3. **find before replace** — no live conflict; `f`/`t` and `r` arm on
112/// disjoint keys and neither can be pending while the other is. Ordered
113/// for stability rather than necessity, and said so rather than implying a
114/// constraint that is not there.
115/// 4. **all four before the sequence stepper and the keymap** — this is the
116/// whole point. Each capture also declines while `pending_keys` is
117/// non-empty, so a LATER key of a gesture (`zt`'s `t`) belongs to the
118/// sequence rather than arming a till-find.
119static OPERAND_CHAIN: &[OperandCapture] = &[
120 OperandCapture {
121 name: "mark",
122 claim: KeyPipeline::claim_mark,
123 count: OperandCount::Drained,
124 },
125 OperandCapture {
126 name: "object",
127 claim: KeyPipeline::claim_object,
128 count: OperandCount::SelfCounted,
129 },
130 OperandCapture {
131 name: "find",
132 claim: KeyPipeline::claim_find,
133 count: OperandCount::Drained,
134 },
135 OperandCapture {
136 name: "replace",
137 claim: KeyPipeline::claim_replace,
138 count: OperandCount::Drained,
139 },
140];
141
142/// The operand-capture chain's order, by name — the gate
143/// `escriba-runtime/tests/operand_capture_order.rs` pins it.
144#[must_use]
145pub fn operand_capture_order() -> Vec<&'static str> {
146 OPERAND_CHAIN.iter().map(|c| c.name).collect()
147}
148
149/// Outcome of the multi-key sequence stepper.
150enum SeqStep {
151 /// The key began or extended a live sequence prefix; hold it.
152 Pending,
153 /// The key completed a bound sequence.
154 Resolved(Action),
155 /// Not a sequence key; hand it to single-key dispatch.
156 Passthrough,
157}
158
159/// The vim key layer: keymap + every piece of half-typed-gesture state.
160///
161/// See the module docs for the contract. Construct with
162/// [`KeyPipeline::default_vim`] (or [`KeyPipeline::new`] over a customised
163/// [`Keymap`]), then hand every key to [`KeyPipeline::feed`].
164#[derive(Debug, Clone)]
165pub struct KeyPipeline {
166 keymap: Keymap,
167 /// Keys accumulated for an in-progress multi-key sequence — e.g. holding
168 /// `[,, f]` while waiting for the final key of `<leader>ff`. Empty when
169 /// not mid-sequence.
170 pending_keys: Vec<Key>,
171 /// The operator-pending machine (`d`/`c`/`y` then a motion → `dw`/`c$`),
172 /// standing on the fleet `zenmai` Mealy-machine primitive.
173 op: zenmai::Stateful<OperatorPending>,
174 /// `Some(around)` means an operator + `i`/`a` were pressed and the NEXT
175 /// key names the object. Held at the KEY layer because the machine sees
176 /// `Action`s and this decision needs the KEY: `a` and every bracket are
177 /// unbound in Normal, so they all arrive as `Action::Pending` with the
178 /// character already discarded. vim has a whole operator-pending keymap
179 /// for the same reason.
180 pending_object: Option<bool>,
181 /// `f`/`F`/`t`/`T` was pressed and the NEXT key is the character to
182 /// search for. It must be claimed before the sequence stepper or `f` then
183 /// `f` would resolve as a bound `ff` sequence.
184 pending_find: Option<FindSpec>,
185 /// `r` was pressed and the NEXT key is the replacement. `rw` must not read
186 /// as `r` then *move a word*.
187 pending_replace: bool,
188 /// `m`, `` ` `` or `'` was pressed and the NEXT key is the mark letter.
189 pending_mark: Option<MarkKey>,
190 /// The last resolved character search — what `;` and `,` repeat. Memory,
191 /// not pending state: [`Self::reset`] keeps it.
192 last_find: Option<FindSpec>,
193}
194
195impl Default for KeyPipeline {
196 fn default() -> Self {
197 Self::default_vim()
198 }
199}
200
201impl KeyPipeline {
202 /// A pipeline over `keymap`, with nothing half-typed.
203 #[must_use]
204 pub fn new(keymap: Keymap) -> Self {
205 Self {
206 keymap,
207 pending_keys: Vec::new(),
208 op: zenmai::Stateful::new(OpState::Resting),
209 pending_object: None,
210 pending_find: None,
211 pending_replace: false,
212 pending_mark: None,
213 last_find: None,
214 }
215 }
216
217 /// A pipeline over [`Keymap::default_vim`].
218 #[must_use]
219 pub fn default_vim() -> Self {
220 Self::new(Keymap::default_vim())
221 }
222
223 #[must_use]
224 pub const fn keymap(&self) -> &Keymap {
225 &self.keymap
226 }
227
228 /// The live keymap — rc / plugin binding application writes here.
229 pub const fn keymap_mut(&mut self) -> &mut Keymap {
230 &mut self.keymap
231 }
232
233 /// The keys held for an in-progress multi-key sequence (empty when none).
234 /// What a "showcmd" indicator renders.
235 #[must_use]
236 pub fn pending_keys(&self) -> &[Key] {
237 &self.pending_keys
238 }
239
240 /// The operator machine's state (`Resting`, `Awaiting`, `AwaitingSearch`).
241 #[must_use]
242 pub fn op_state(&self) -> &OpState {
243 self.op.state()
244 }
245
246 /// The last completed `f`/`F`/`t`/`T` — what `;` / `,`
247 /// ([`Motion::RepeatFind`]) resolve through. Resolving it needs the
248 /// buffer, so that is the host's job; recording it is the key layer's.
249 #[must_use]
250 pub const fn last_find(&self) -> Option<FindSpec> {
251 self.last_find
252 }
253
254 /// True while a key sequence, an operator (including `d/` with its search
255 /// prompt open), an object, a find, a replace or a mark is half-typed.
256 ///
257 /// A bare COUNT prefix is not included: it lives in [`ModalState`]
258 /// (`pending_count()`), which the host already holds.
259 #[must_use]
260 pub fn is_pending(&self) -> bool {
261 !self.pending_keys.is_empty()
262 || self.pending_object.is_some()
263 || self.pending_find.is_some()
264 || self.pending_replace
265 || self.pending_mark.is_some()
266 || !matches!(self.op.state(), OpState::Resting)
267 }
268
269 /// Abandon everything half-typed and disarm the operator machine. The
270 /// keymap and [`Self::last_find`] are kept. The count prefix lives in the
271 /// host's [`ModalState`]; clear it there.
272 pub fn reset(&mut self) {
273 self.pending_keys.clear();
274 self.op = zenmai::Stateful::new(OpState::Resting);
275 self.pending_object = None;
276 self.pending_find = None;
277 self.pending_replace = false;
278 self.pending_mark = None;
279 }
280
281 /// Feed one key; get the fully composed steps to execute, in order.
282 ///
283 /// Each `(Action, u32)` has passed the operator machine: `3d2w` yields one
284 /// `ApplyOperator { Delete, WordStartNext }` at count 6, `ciw` one
285 /// `ApplyOperatorObject`, `fx` a `Move(FindChar …)`, `gg` the resolved
286 /// sequence. The count is a REPETITION count for most actions; an editor
287 /// that treats some actions as absorbing their count (`3p`, `2dd` as one
288 /// operation) decides that itself.
289 ///
290 /// Counts accumulate in `modal`; the pipeline never mutates text and never
291 /// changes the mode — mode changes come back as actions.
292 ///
293 /// This is [`Self::resolve_key`] followed by [`Self::compose`] on every
294 /// unit. A host that must be able to refuse a unit before the operator
295 /// machine sees it (see the module docs) calls those two itself.
296 pub fn feed(&mut self, modal: &mut ModalState, key: &Key) -> Vec<(Action, u32)> {
297 let units = self.resolve_key(modal, key);
298 let mut out = Vec::with_capacity(units.len());
299 for (action, count) in units {
300 out.extend(self.compose(&action, count));
301 }
302 out
303 }
304
305 /// Run the operator machine over one resolved unit.
306 ///
307 /// Also the entry for actions that did not come from a key — anything a
308 /// host dispatches should pass here, so an armed `d` composes with it the
309 /// same way it composes with a keyed motion.
310 ///
311 /// `|` is folded in first: it is the one motion whose count is an
312 /// ARGUMENT rather than a repetition (`40|` is column 40, not "column 1,
313 /// forty times"). Folding it here keeps the machine at one rule — counts
314 /// repeat — with the exception living where the exception is.
315 pub fn compose(&mut self, action: &Action, count: u32) -> Vec<(Action, u32)> {
316 let (action, count) = match action {
317 Action::Move(Motion::Column(_)) => (Action::Move(Motion::Column(count)), 1),
318 a => (a.clone(), count),
319 };
320 self.op.dispatch((action, count))
321 }
322
323 /// The key layer alone: captures, sequences, counts, keymap. Returns the
324 /// units to hand to [`Self::compose`], one at a time and in order.
325 ///
326 /// A repeated sequence (`3gg`) and a counted object (`2diw`) come back as
327 /// that many count-1 units rather than one counted unit, because that is
328 /// how they meet the operator machine: each repetition is its own event
329 /// (`d3gg` composes the first and runs the other two bare).
330 pub fn resolve_key(&mut self, modal: &mut ModalState, key: &Key) -> Vec<(Action, u32)> {
331 // ── OPERAND CAPTURE — the keys that are ARGUMENTS, not bindings ──
332 //
333 // `di(`, `fx`, `` `a ``, `rZ`: in each, the second keystroke is an
334 // operand of a half-typed gesture and must be claimed before the
335 // sequence stepper and before the keymap, or it resolves as whatever
336 // it happens to be bound to (`i` enters Insert, `w` moves a word).
337 // A TABLE, because the order is the correctness property.
338 for cap in OPERAND_CHAIN {
339 let Some(claim) = (cap.claim)(self, modal, *key) else {
340 continue;
341 };
342 return match claim {
343 Claim::Consumed => Vec::new(),
344 Claim::Compose { action, times } => match cap.count {
345 OperandCount::SelfCounted => vec![(action, 1); times.max(1) as usize],
346 OperandCount::Drained => {
347 let n = modal.pending_count().unwrap_or(1);
348 modal.clear_count();
349 vec![(action, n)]
350 }
351 },
352 };
353 }
354 // Multi-key sequence resolution: a key that begins or continues a
355 // bound sequence (`<leader>ff`, `gg`) is held or resolved here before
356 // the single-key path sees it.
357 match self.step_sequence(modal.mode(), *key) {
358 SeqStep::Pending => return Vec::new(),
359 SeqStep::Resolved(action) => {
360 let count = modal.pending_count().unwrap_or(1);
361 modal.clear_count();
362 return vec![(action, 1); count as usize];
363 }
364 SeqStep::Passthrough => {}
365 }
366 let counted = self.keymap.dispatch(modal, key);
367 if matches!(counted.action, Action::Pending) {
368 match key {
369 // Count prefixes accumulate into modal state.
370 Key::Char(c) => {
371 if let Some(d) = c.to_digit(10) {
372 modal.append_count(d);
373 }
374 }
375 // An UNBOUND Esc (Normal has nothing to leave) abandons the
376 // half-typed command, as vim's does: operator and count. The
377 // operator machine cannot do this itself — `Pending` must not
378 // disarm it, or a sequence key after `d` (`dgg`) would cancel
379 // the operator. So the KEY decides, here. A bound Esc (every
380 // other mode, or an rc binding) never reaches this arm; the
381 // machine already treats its action as a cancel.
382 Key::Esc => {
383 if self.operator_armed() {
384 self.disarm();
385 }
386 modal.clear_count();
387 }
388 _ => {}
389 }
390 return Vec::new();
391 }
392 // The count flows through the operator machine, which owns
393 // repetition: a bare motion runs count×, an operator captures its
394 // count, and an operated motion multiplies the two.
395 modal.clear_count();
396 vec![(counted.action, counted.count)]
397 }
398
399 /// Advance the multi-key pending-stroke state machine for `key`.
400 ///
401 /// Sequences only apply in normal / visual modes — insert and command
402 /// modes treat keys as literal text. Rules:
403 /// - Mid-sequence: extend the pending prefix. Exact match → `Resolved`;
404 /// still a live prefix → `Pending`; otherwise abort the sequence and
405 /// re-process this key fresh.
406 /// - Not mid-sequence: if `key` begins a bound sequence AND is not itself
407 /// a complete single binding (single bindings win, so no chord timeout
408 /// is needed) → start pending. Otherwise `Passthrough`.
409 fn step_sequence(&mut self, mode: Mode, key: Key) -> SeqStep {
410 if !matches!(mode, Mode::Normal | Mode::Visual | Mode::VisualLine) {
411 return SeqStep::Passthrough;
412 }
413 if !self.pending_keys.is_empty() {
414 let mut seq = self.pending_keys.clone();
415 seq.push(key);
416 if let Some(b) = self.keymap.lookup_sequence(mode, &seq) {
417 let action = b.action.clone();
418 self.pending_keys.clear();
419 return SeqStep::Resolved(action);
420 }
421 if self.keymap.is_sequence_prefix(mode, &seq) {
422 self.pending_keys = seq;
423 return SeqStep::Pending;
424 }
425 // The key broke the in-progress sequence — abort it and let the
426 // key be re-processed as a fresh stroke below.
427 self.pending_keys.clear();
428 }
429 let start = [key];
430 if self.keymap.is_sequence_prefix(mode, &start) && self.keymap.lookup(mode, &key).is_none()
431 {
432 self.pending_keys = start.to_vec();
433 return SeqStep::Pending;
434 }
435 SeqStep::Passthrough
436 }
437
438 fn operator_armed(&self) -> bool {
439 matches!(self.op.state(), OpState::Awaiting { .. })
440 }
441
442 /// Disarm the operator machine, discarding what it emits. `Esc` is the
443 /// event the machine already treats as "cancel whatever is armed".
444 fn disarm(&mut self) {
445 self.op.dispatch((Action::ChangeMode(Mode::Normal), 1));
446 }
447
448 /// Claim the operand of a pending `m` / `` ` `` / `'`, or arm one.
449 ///
450 /// `ma` is `m` plus an OPERAND, and `a` is bound (append). Without
451 /// claiming it first, `ma` would set no mark and enter Insert mode.
452 ///
453 /// Arms only while `pending_object` is clear, so `di'` — where `'` is a
454 /// text-object delimiter rather than a mark jump — still reaches the
455 /// object path. The guard states that dependency locally instead of
456 /// leaving it implied by chain order.
457 fn claim_mark(&mut self, modal: &ModalState, key: Key) -> Option<Claim> {
458 if let Some(kind) = self.pending_mark.take() {
459 let Key::Char(name) = key else {
460 if self.operator_armed() {
461 self.disarm();
462 }
463 return Some(Claim::Consumed);
464 };
465 let action = match kind {
466 MarkKey::Set => Action::SetMark(name),
467 MarkKey::GotoExact => Action::Move(Motion::MarkExact(name)),
468 MarkKey::GotoLine => Action::Move(Motion::MarkLine(name)),
469 };
470 return Some(Claim::Compose { action, times: 1 });
471 }
472 if !matches!(modal.mode(), Mode::Normal | Mode::Visual) {
473 return None;
474 }
475 // Half-typed text object (`di` waiting for its `'`) belongs to the
476 // object path; a key continuing a sequence belongs to the sequence
477 // (see `claim_find` for the `zt` case that proves it).
478 if self.pending_object.is_some() || !self.pending_keys.is_empty() {
479 return None;
480 }
481 let Key::Char(c) = key else { return None };
482 let kind = match c {
483 'm' => MarkKey::Set,
484 '`' => MarkKey::GotoExact,
485 '\'' => MarkKey::GotoLine,
486 _ => return None,
487 };
488 self.pending_mark = Some(kind);
489 Some(Claim::Consumed)
490 }
491
492 /// Read one key as operator-pending OBJECT selection (`diw`, `ca(`).
493 ///
494 /// Returns `None` when the key is nothing to do with objects, so the
495 /// ordinary path runs untouched.
496 fn claim_object(&mut self, _modal: &ModalState, key: Key) -> Option<Claim> {
497 let Key::Char(c) = key else {
498 // Esc (or anything non-printable) abandons a half-typed object
499 // rather than leaving the editor silently armed.
500 if self.pending_object.take().is_some() {
501 self.disarm();
502 return Some(Claim::Consumed);
503 }
504 return None;
505 };
506
507 // Second key: it names the object.
508 if let Some(around) = self.pending_object.take() {
509 let object = object_for(c, around);
510 let OpState::Awaiting { op, count } = *self.op.state() else {
511 return Some(Claim::Consumed);
512 };
513 // Disarm either way: an unknown object key cancels the operator,
514 // it does not leave it armed for the next unrelated keystroke.
515 self.disarm();
516 let Some(object) = object else {
517 return Some(Claim::Consumed);
518 };
519 // `2diw` applies the object twice — the OPERATOR's count, which
520 // only the armed machine knew.
521 return Some(Claim::Compose {
522 action: Action::ApplyOperatorObject { op, object },
523 times: count,
524 });
525 }
526
527 // First key: `i` or `a` while an operator waits.
528 if matches!(c, 'i' | 'a') && self.operator_armed() {
529 self.pending_object = Some(c == 'a');
530 return Some(Claim::Consumed);
531 }
532 None
533 }
534
535 /// Claim the operand of a pending `f`/`F`/`t`/`T`, or arm one.
536 ///
537 /// The character is an OPERAND, not a binding: `dfx` is undecidable from
538 /// actions — `x` would resolve as whatever `x` is bound to. Composition
539 /// with an operator is free: the armed motion is emitted as an ordinary
540 /// `Action::Move`, so the operator machine composes `dfx` exactly the way
541 /// it composes `dw`.
542 fn claim_find(&mut self, modal: &ModalState, key: Key) -> Option<Claim> {
543 if let Some(spec) = self.pending_find.take() {
544 let Key::Char(ch) = key else {
545 // Esc (or any non-printable) abandons a half-typed find rather
546 // than leaving the editor armed for the next keystroke.
547 if self.operator_armed() {
548 self.disarm();
549 }
550 return Some(Claim::Consumed);
551 };
552 let spec = FindSpec { ch, ..spec };
553 self.last_find = Some(spec);
554 return Some(Claim::Compose {
555 action: Action::Move(Motion::FindChar {
556 ch,
557 backward: spec.backward,
558 till: spec.till,
559 }),
560 times: 1,
561 });
562 }
563 if modal.mode() != Mode::Normal && modal.mode() != Mode::Visual {
564 return None;
565 }
566 // A key that is CONTINUING a sequence belongs to the sequence.
567 //
568 // Without this, `zt` was unreachable: `z` starts a pending sequence,
569 // then `t` was claimed here as a till-find and the sequence never
570 // completed. "An operand key outranks a binding" is right for the
571 // FIRST key of a gesture and wrong for a later one. The operand branch
572 // above runs before this guard, so `zt` and `ft` are both reachable.
573 if !self.pending_keys.is_empty() {
574 return None;
575 }
576 let Key::Char(c) = key else { return None };
577 let (backward, till) = match c {
578 'f' => (false, false),
579 'F' => (true, false),
580 't' => (false, true),
581 'T' => (true, true),
582 _ => return None,
583 };
584 self.pending_find = Some(FindSpec {
585 ch: '\0',
586 backward,
587 till,
588 });
589 Some(Claim::Consumed)
590 }
591
592 /// Claim the operand of a pending `r`, or arm one.
593 ///
594 /// `r` is unbound in the keymap on purpose: a binding on it would be a
595 /// table entry no keypress can reach — configured on paper, absent in
596 /// behaviour, the exact trap `f`/`t` documented.
597 fn claim_replace(&mut self, modal: &ModalState, key: Key) -> Option<Claim> {
598 if self.pending_replace {
599 self.pending_replace = false;
600 let Key::Char(ch) = key else {
601 // Esc (or any non-printable) abandons a half-typed `r` rather
602 // than replacing with something unprintable.
603 return Some(Claim::Consumed);
604 };
605 return Some(Claim::Compose {
606 action: Action::ReplaceChar(ch),
607 times: 1,
608 });
609 }
610 if modal.mode() != Mode::Normal && modal.mode() != Mode::Visual {
611 return None;
612 }
613 // A key CONTINUING a sequence belongs to the sequence — the `zt` rule.
614 if !self.pending_keys.is_empty() {
615 return None;
616 }
617 if key != Key::Char('r') {
618 return None;
619 }
620 // `r` is not a motion, so `dr` is a typo — and vim treats it as one by
621 // CANCELLING the operator. Falling through to the keymap instead is
622 // the worse reading: `r` is unbound, so it resolves to
623 // `Action::Pending`, which the machine deliberately lets leave the
624 // operator armed (for the multi-key-sequence case). The next motion
625 // would then delete.
626 if self.operator_armed() {
627 self.disarm();
628 return Some(Claim::Consumed);
629 }
630 self.pending_replace = true;
631 Some(Claim::Consumed)
632 }
633}
634
635/// The text object named by `c` after `i` (`around == false`) or `a`.
636fn object_for(c: char, around: bool) -> Option<TextObject> {
637 let delimited = |open, close| {
638 Some(TextObject::Delimited {
639 open,
640 close,
641 around,
642 })
643 };
644 match c {
645 'w' => Some(TextObject::Word { around }),
646 // vim's `b` and `B` aliases for the bracket pairs, plus the brackets
647 // themselves in both directions.
648 '(' | ')' | 'b' => delimited('(', ')'),
649 '{' | '}' | 'B' => delimited('{', '}'),
650 '[' | ']' => delimited('[', ']'),
651 '<' | '>' => delimited('<', '>'),
652 // Quotes: `open == close`, which is what tells the resolver not to
653 // count nesting.
654 '"' => delimited('"', '"'),
655 '\'' => delimited('\'', '\''),
656 '`' => delimited('`', '`'),
657 _ => None,
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664 use escriba_core::{InsertAt, Operator};
665
666 fn normal() -> ModalState {
667 ModalState::new()
668 }
669
670 /// Feed a string of printable keys; collect every emitted step.
671 fn type_keys(p: &mut KeyPipeline, m: &mut ModalState, keys: &str) -> Vec<(Action, u32)> {
672 keys.chars()
673 .flat_map(|c| p.feed(m, &Key::Char(c)))
674 .collect()
675 }
676
677 #[test]
678 fn dw_composes_one_apply_operator() {
679 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
680 assert!(p.feed(&mut m, &Key::Char('d')).is_empty(), "d waits");
681 assert!(p.is_pending());
682 assert_eq!(
683 p.feed(&mut m, &Key::Char('w')),
684 vec![(
685 Action::ApplyOperator {
686 op: Operator::Delete,
687 motion: Motion::WordStartNext
688 },
689 1
690 )]
691 );
692 assert!(!p.is_pending());
693 }
694
695 #[test]
696 fn three_d_two_w_is_one_operation_at_count_six() {
697 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
698 assert_eq!(
699 type_keys(&mut p, &mut m, "3d2w"),
700 vec![(
701 Action::ApplyOperator {
702 op: Operator::Delete,
703 motion: Motion::WordStartNext
704 },
705 6
706 )]
707 );
708 assert_eq!(m.pending_count(), None, "the count was drained");
709 }
710
711 #[test]
712 fn ciw_composes_one_object_operation() {
713 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
714 assert_eq!(
715 type_keys(&mut p, &mut m, "ciw"),
716 vec![(
717 Action::ApplyOperatorObject {
718 op: Operator::Change,
719 object: TextObject::Word { around: false }
720 },
721 1
722 )]
723 );
724 assert!(!p.is_pending());
725 }
726
727 #[test]
728 fn di_paren_is_an_object_not_insert() {
729 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
730 assert_eq!(
731 type_keys(&mut p, &mut m, "di("),
732 vec![(
733 Action::ApplyOperatorObject {
734 op: Operator::Delete,
735 object: TextObject::Delimited {
736 open: '(',
737 close: ')',
738 around: false
739 }
740 },
741 1
742 )]
743 );
744 }
745
746 #[test]
747 fn a_counted_object_emits_one_unit_per_repeat() {
748 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
749 let steps = type_keys(&mut p, &mut m, "2daw");
750 let obj = Action::ApplyOperatorObject {
751 op: Operator::Delete,
752 object: TextObject::Word { around: true },
753 };
754 assert_eq!(steps, vec![(obj.clone(), 1), (obj, 1)]);
755 }
756
757 #[test]
758 fn fx_is_a_find_motion_and_is_remembered() {
759 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
760 let find = Action::Move(Motion::FindChar {
761 ch: 'x',
762 backward: false,
763 till: false,
764 });
765 assert_eq!(type_keys(&mut p, &mut m, "fx"), vec![(find, 1)]);
766 assert_eq!(
767 p.last_find(),
768 Some(FindSpec {
769 ch: 'x',
770 backward: false,
771 till: false
772 })
773 );
774 }
775
776 #[test]
777 fn a_counted_find_carries_the_count_once() {
778 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
779 let steps = type_keys(&mut p, &mut m, "3f.");
780 assert_eq!(steps.len(), 1);
781 assert_eq!(steps[0].1, 3, "3f. is the third dot, not the ninth");
782 }
783
784 #[test]
785 fn dtx_composes_a_till_find() {
786 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
787 assert_eq!(
788 type_keys(&mut p, &mut m, "dtx"),
789 vec![(
790 Action::ApplyOperator {
791 op: Operator::Delete,
792 motion: Motion::FindChar {
793 ch: 'x',
794 backward: false,
795 till: true
796 }
797 },
798 1
799 )]
800 );
801 }
802
803 #[test]
804 fn r_takes_its_operand_even_when_bound() {
805 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
806 assert_eq!(
807 type_keys(&mut p, &mut m, "rw"),
808 vec![(Action::ReplaceChar('w'), 1)]
809 );
810 }
811
812 #[test]
813 fn dr_cancels_the_operator() {
814 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
815 assert!(type_keys(&mut p, &mut m, "dr").is_empty());
816 assert!(!p.is_pending(), "the typo disarmed rather than arming r");
817 }
818
819 #[test]
820 fn gg_resolves_the_sequence() {
821 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
822 assert!(p.feed(&mut m, &Key::Char('g')).is_empty());
823 assert_eq!(p.pending_keys(), &[Key::Char('g')]);
824 assert!(p.is_pending());
825 assert_eq!(
826 p.feed(&mut m, &Key::Char('g')),
827 vec![(Action::Move(Motion::DocStart), 1)]
828 );
829 assert!(p.pending_keys().is_empty());
830 }
831
832 #[test]
833 fn dgg_composes_through_the_sequence() {
834 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
835 assert_eq!(
836 type_keys(&mut p, &mut m, "dgg"),
837 vec![(
838 Action::ApplyOperator {
839 op: Operator::Delete,
840 motion: Motion::DocStart
841 },
842 1
843 )]
844 );
845 }
846
847 #[test]
848 fn capital_g_is_a_single_key() {
849 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
850 assert_eq!(
851 p.feed(&mut m, &Key::Char('G')),
852 vec![(Action::Move(Motion::DocEnd), 1)]
853 );
854 }
855
856 #[test]
857 fn digit_counts_accumulate() {
858 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
859 assert!(p.feed(&mut m, &Key::Char('1')).is_empty());
860 assert!(p.feed(&mut m, &Key::Char('5')).is_empty());
861 assert_eq!(m.pending_count(), Some(15));
862 assert_eq!(
863 p.feed(&mut m, &Key::Char('j')),
864 vec![(Action::Move(Motion::Down), 15)]
865 );
866 assert_eq!(m.pending_count(), None);
867 }
868
869 #[test]
870 fn five_j() {
871 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
872 assert_eq!(
873 type_keys(&mut p, &mut m, "5j"),
874 vec![(Action::Move(Motion::Down), 5)]
875 );
876 }
877
878 #[test]
879 fn zero_is_a_motion_alone_and_a_digit_mid_count() {
880 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
881 assert_eq!(
882 p.feed(&mut m, &Key::Char('0')),
883 vec![(Action::Move(Motion::LineStart), 1)]
884 );
885 assert_eq!(
886 type_keys(&mut p, &mut m, "10j"),
887 vec![(Action::Move(Motion::Down), 10)]
888 );
889 }
890
891 #[test]
892 fn esc_cancels_a_half_typed_operator_and_its_count() {
893 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
894 type_keys(&mut p, &mut m, "3d");
895 assert!(p.is_pending());
896 assert!(p.feed(&mut m, &Key::Esc).is_empty(), "Esc is dropped");
897 assert!(!p.is_pending());
898 // A following motion is a bare motion, not a delete.
899 assert_eq!(
900 p.feed(&mut m, &Key::Char('w')),
901 vec![(Action::Move(Motion::WordStartNext), 1)]
902 );
903 type_keys(&mut p, &mut m, "5");
904 p.feed(&mut m, &Key::Esc);
905 assert_eq!(m.pending_count(), None, "a bare count dies with Esc too");
906 }
907
908 #[test]
909 fn a_sequence_key_after_an_operator_keeps_it_armed() {
910 // The reason Esc is decided at the KEY layer: `g` resolves to
911 // `Pending` mid-`dgg` and must not disarm the `d`.
912 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
913 type_keys(&mut p, &mut m, "dg");
914 assert!(matches!(p.op_state(), OpState::Awaiting { .. }));
915 }
916
917 #[test]
918 fn esc_cancels_a_half_typed_object() {
919 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
920 type_keys(&mut p, &mut m, "di");
921 assert!(p.is_pending());
922 assert!(p.feed(&mut m, &Key::Esc).is_empty());
923 assert!(!p.is_pending(), "object AND operator disarmed");
924 assert_eq!(
925 p.feed(&mut m, &Key::Char('w')),
926 vec![(Action::Move(Motion::WordStartNext), 1)]
927 );
928 }
929
930 #[test]
931 fn is_pending_truth_table() {
932 let cases: &[(&str, bool)] = &[
933 ("", false),
934 ("d", true),
935 ("di", true),
936 ("f", true),
937 ("df", true),
938 ("r", true),
939 ("m", true),
940 ("`", true),
941 ("g", true),
942 ("5", false), // a count lives in ModalState, not here
943 ("dw", false),
944 ("fx", false),
945 ("rx", false),
946 ("ma", false),
947 ("gg", false),
948 ("j", false),
949 ];
950 for (keys, want) in cases {
951 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
952 type_keys(&mut p, &mut m, keys);
953 assert_eq!(p.is_pending(), *want, "after {keys:?}");
954 }
955 }
956
957 #[test]
958 fn reset_abandons_everything_half_typed() {
959 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
960 type_keys(&mut p, &mut m, "fx");
961 type_keys(&mut p, &mut m, "dg");
962 assert!(p.is_pending());
963 p.reset();
964 assert!(!p.is_pending());
965 assert!(p.last_find().is_some(), "memory survives a reset");
966 }
967
968 #[test]
969 fn insert_mode_keys_pass_through_as_text() {
970 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
971 assert_eq!(
972 p.feed(&mut m, &Key::Char('i')),
973 vec![(Action::EnterInsert(InsertAt::Caret), 1)]
974 );
975 // The host applies the mode change; the pipeline only reads it.
976 m.enter_insert();
977 for c in ['g', 'g', 'f', 'd', 'r', 'm', '5', 'i'] {
978 assert_eq!(
979 p.feed(&mut m, &Key::Char(c)),
980 vec![(Action::InsertChar(c), 1)],
981 "{c:?} in Insert is text"
982 );
983 assert!(!p.is_pending(), "{c:?} armed nothing in Insert");
984 }
985 }
986
987 #[test]
988 fn marks_capture_their_letter() {
989 let (mut p, mut m) = (KeyPipeline::default_vim(), normal());
990 assert_eq!(
991 type_keys(&mut p, &mut m, "ma"),
992 vec![(Action::SetMark('a'), 1)]
993 );
994 assert_eq!(
995 type_keys(&mut p, &mut m, "d`a"),
996 vec![(
997 Action::ApplyOperator {
998 op: Operator::Delete,
999 motion: Motion::MarkExact('a')
1000 },
1001 1
1002 )]
1003 );
1004 }
1005
1006 #[test]
1007 fn a_column_count_is_an_argument() {
1008 let mut p = KeyPipeline::default_vim();
1009 assert_eq!(
1010 p.compose(&Action::Move(Motion::Column(0)), 40),
1011 vec![(Action::Move(Motion::Column(40)), 1)]
1012 );
1013 }
1014
1015 #[test]
1016 fn the_chain_order_is_mark_object_find_replace() {
1017 assert_eq!(
1018 operand_capture_order(),
1019 vec!["mark", "object", "find", "replace"]
1020 );
1021 }
1022}