Skip to main content

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}