Skip to main content

escriba_core/
action.rs

1use escriba_search::Direction as SearchDirection;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4
5use crate::edit::Edit;
6use crate::mode::Mode;
7use crate::motion::TextObject;
8use crate::motion::{Motion, Operator};
9
10/// WHERE the caret lands when Insert mode is entered — vim's insert-entry
11/// family (`i` `I` `a` `A` `o` `O`) as ONE typed surface.
12///
13/// Until 2026-08-12 the family was a single key. `i` was bound straight to
14/// `Action::ChangeMode(Mode::Insert)` and the other five did not exist —
15/// pressing `A` in escriba 0.1.71 moved nothing, changed no mode, and reported
16/// nothing, because an unbound Normal-mode key resolves to `Action::Pending`.
17/// The absence was invisible from inside: `escriba --keymap` lists what IS
18/// bound, so nothing named the four keys a vim user reaches for first.
19///
20/// Modelling the ENTRY POINT rather than the destination mode is what makes
21/// that class of omission unrepresentable. A new entry is a variant here, and
22/// [`Action::text_effect`], [`Action::highlight_effect`],
23/// [`Action::edits_prompt`] and the runtime's damage classifier are all total
24/// over `Action` — so adding one cannot compile until every consequence has
25/// been decided.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
27pub enum InsertAt {
28    /// `i` — insert BEFORE the caret. The caret does not move.
29    Caret,
30    /// `I` — the first non-blank character of the line.
31    FirstNonBlank,
32    /// `a` — one column right of the caret ("append"), clamped to one past the
33    /// last character so `a` on the final character still appends.
34    AfterCaret,
35    /// `A` — one column past the last character of the line.
36    LineEnd,
37    /// `o` — open a fresh line BELOW the caret's and land on it.
38    OpenBelow,
39    /// `O` — open a fresh line ABOVE the caret's and land on it.
40    OpenAbove,
41}
42
43impl InsertAt {
44    /// Does this entry ADD a line to the buffer?
45    ///
46    /// `o` and `O` are the only two members of the family that change text;
47    /// the other four move the caret and nothing else. Read by
48    /// [`Action::text_effect`], so the answer lives here beside the variants
49    /// rather than being re-derived by each classifier that needs it.
50    #[must_use]
51    pub const fn opens_a_line(self) -> bool {
52        matches!(self, Self::OpenBelow | Self::OpenAbove)
53    }
54
55    /// The stable label — `escriba --keymap`, the command palette, the rc's
56    /// `:action` names.
57    #[must_use]
58    pub const fn as_str(self) -> &'static str {
59        match self {
60            Self::Caret => "caret",
61            Self::FirstNonBlank => "first-non-blank",
62            Self::AfterCaret => "after-caret",
63            Self::LineEnd => "line-end",
64            Self::OpenBelow => "open-below",
65            Self::OpenAbove => "open-above",
66        }
67    }
68
69    /// Every entry, so a matrix test cannot silently miss one.
70    pub const ALL: [Self; 6] = [
71        Self::Caret,
72        Self::FirstNonBlank,
73        Self::AfterCaret,
74        Self::LineEnd,
75        Self::OpenBelow,
76        Self::OpenAbove,
77    ];
78}
79
80/// A fully-resolved editor action — what the keymap emits, what the buffer
81/// consumes.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
83pub enum Action {
84    /// Move every cursor by `motion`.
85    Move(Motion),
86    /// Begin an operator (the `d`/`c`/`y` key). The editor enters
87    /// operator-pending: the next motion composes into an [`Action::ApplyOperator`].
88    /// Resolved by the operator-pending FSM, never executed directly.
89    Operator(Operator),
90    /// Apply a pending operator over a motion (delete-word, yank-line, etc.).
91    ApplyOperator {
92        op: Operator,
93        motion: Motion,
94    },
95    /// Apply a primitive edit at each cursor.
96    Edit(Edit),
97    /// Enter the given mode.
98    ChangeMode(Mode),
99    /// Enter Insert mode AT a named place — vim's `i` `I` `a` `A` `o` `O`.
100    ///
101    /// Distinct from `ChangeMode(Mode::Insert)`, which is still what the
102    /// *runtime* emits when an operator leaves you inserting (`ciw`) and what
103    /// `<Esc>`'s counterpart looks like. This variant is the KEYBOARD's entry
104    /// point, and it carries the one thing `ChangeMode` cannot: where the caret
105    /// goes. See [`InsertAt`].
106    EnterInsert(InsertAt),
107    /// Run a named command (via the command registry).
108    Command {
109        name: String,
110        args: Vec<String>,
111    },
112    /// Insert a character at each caret. Separate from Edit so the keymap
113    /// can stay ignorant of rope details.
114    InsertChar(char),
115    /// Submit a minibuffer / command-mode line (e.g. `:w`, `:q`).
116    SubmitCommand,
117    /// Undo / redo one change.
118    Undo,
119    Redo,
120    /// Save the current buffer.
121    Save,
122    /// Quit the editor.
123    Quit,
124    // ── search (vim `/`, `?`, `n`, `N`, `*`, `#`) ──────────────────────
125    /// Open the search prompt in `direction` (the `/` and `?` keys).
126    ///
127    /// The prompt reuses `Mode::Command` rather than adding a mode variant:
128    /// vim's `/` IS the command-line with a different prompt character, and
129    /// this module's own doc states new modes are layered through pending
130    /// state, not new variants. `SearchState`'s typed `Option<Prompt>` is what
131    /// disambiguates a `<CR>` that submits a search from one that submits an
132    /// ex-command — a discriminator that cannot be forgotten, unlike a bool.
133    SearchOpen(SearchDirection),
134    /// `n` (`reverse = false`) / `N` (`reverse = true`) — jump to the next
135    /// match, relative to the direction the search was committed with, so `N`
136    /// after a `?` search moves forward.
137    SearchRepeat {
138        reverse: bool,
139    },
140    /// `*` (`reverse = false`) / `#` (`reverse = true`) — search the whole word
141    /// under the cursor. Literal, not regex: the word may contain `.` or `[`
142    /// and the user means those characters.
143    SearchWord {
144        reverse: bool,
145    },
146    /// `:noh` — stop highlighting matches while keeping the pattern, so `n`
147    /// still works. Distinct from cancelling a search.
148    ClearSearchHighlight,
149
150    /// `d/foo<CR>` — commit the open search prompt and apply `op` from the
151    /// prompt's ORIGIN to wherever the search lands.
152    ///
153    /// Emitted only by the operator-pending machine; no keymap produces it.
154    /// It exists because committing a search MOVES the cursor, and the
155    /// operator needs the pre-move position as its start point. Carrying the
156    /// operator through the commit makes "operate over a search" one atomic
157    /// action instead of two steps racing to own the cursor.
158    SearchSubmitOperated {
159        op: Operator,
160    },
161
162    /// `gn` / `gN` — the next/previous match AS AN OBJECT.
163    ///
164    /// Not a motion. A motion resolves to a POINT and an operator acts over
165    /// `[cursor, point)`; `gn` names an EXTENT that need not start at the
166    /// cursor, so `dgn` deletes the whole match wherever it is. That
167    /// distinction is why this is its own action rather than a `Motion`
168    /// variant — folding it into `Motion` would silently give
169    /// `[cursor, match.start)`, which deletes the text BEFORE the match.
170    TextObject(TextObject),
171
172    /// `{operator}gn` — apply `op` over a text object's extent.
173    ///
174    /// Emitted only by the operator-pending machine.
175    ApplyOperatorObject {
176        op: Operator,
177        object: TextObject,
178    },
179
180    /// `.` — repeat the last text change.
181    ///
182    /// Vim's most-used key, and the half that makes `cgn` a workflow rather
183    /// than a curiosity: `cgn` changes the next match, then `.` changes the
184    /// one after it, giving a per-instance confirmable rename with no
185    /// multi-cursor machinery.
186    RepeatLastChange,
187
188    /// `<C-o>` — walk back to where the last far jump was taken from.
189    ///
190    /// Lives beside the search actions because search is what made it
191    /// necessary — committing a `/` used to be a one-way door — but it is not
192    /// a search action: `G`, `gg`, `%` and tag jumps are the other consumers.
193    JumpBack,
194    /// `<C-i>` — walk forward again after [`Action::JumpBack`].
195    JumpForward,
196    /// `<BS>` — delete the character BEFORE the caret, wherever the caret is.
197    ///
198    /// ONE action, three targets, routed by the runtime: the search prompt,
199    /// the ex command-line, or the buffer in Insert mode. It is deliberately
200    /// not three actions — a face binding `<BS>` should not have to know which
201    /// of the three the operator is currently typing into, and the routing
202    /// question ("is a prompt open?") is already answered by typed state the
203    /// runtime owns.
204    ///
205    /// Named `PromptBackspace` until 2026-08-09, when the Insert-mode target
206    /// landed. The old name was the honest one while the buffer arm did not
207    /// exist — `text_effect` below already described the buffer arm as though
208    /// it did, which is how it went unnoticed that Insert mode had NO way to
209    /// erase a character.
210    Backspace,
211
212    /// Move the caret inside an open prompt (`←` `→` `Home` `End`).
213    ///
214    /// The prompt was append-only until this existed, so a typo in the middle
215    /// of a pattern could only be fixed by deleting back to it.
216    PromptCaret {
217        to: escriba_search::CaretMove,
218    },
219    /// `<C-g>` / `<C-t>` — step the search PREVIEW to the next/previous
220    /// match without committing.
221    ///
222    /// Distinct from `n` in the one way that matters: this is still
223    /// cancellable. Escape returns to where the search started, which `n`
224    /// after a commit cannot do.
225    SearchPreviewStep {
226        forward: bool,
227    },
228    /// `<Del>` — delete the character AT the caret, wherever the caret is.
229    ///
230    /// The forward-delete sibling of [`Action::Backspace`], routed the same
231    /// way. Never closes a prompt: emptying the text by deleting rightwards is
232    /// not the "backspaced past the `/`" gesture that means "I changed my
233    /// mind".
234    DeleteForward,
235    /// `<C-w>` — delete the word before the caret, wherever the caret is.
236    ///
237    /// The word-sized member of the same erase family as [`Action::Backspace`]:
238    /// ONE action, three targets (search prompt / ex line / buffer), routed by
239    /// the runtime on typed state it already owns.
240    ///
241    /// Named `PromptDeleteWord` until 2026-08-09, when the Insert-mode target
242    /// landed. `<BS>` and `<Del>` had been given their buffer arm that morning
243    /// and these two were left behind, so Insert mode could erase one character
244    /// at a time and nothing larger — the half-migration is exactly what the
245    /// `Prompt` prefix was hiding.
246    DeleteWordBefore,
247    /// `<C-u>` — delete from the caret back to the start of the line.
248    ///
249    /// The line-sized member of the erase family; routed exactly like
250    /// [`Action::DeleteWordBefore`]. In the buffer it stops at the first
251    /// non-blank before falling through to column 0, so the first press on an
252    /// indented line clears what was typed and the second clears the indent —
253    /// vim's two-step, which is what keeps `<C-u>` from eating alignment you
254    /// wanted to keep.
255    DeleteToLineStart,
256    /// Up/Down inside a prompt — walk search history.
257    ///
258    /// `back = true` is older. Stepping forward past the newest entry restores
259    /// the text that was being typed when browsing began, so arrowing through
260    /// history and back never destroys a half-typed pattern.
261    PromptHistory {
262        back: bool,
263    },
264    /// No-op — used when a key sequence is pending but not yet complete.
265    Pending,
266}
267
268/// An [`Action`] with an optional repetition count (vim's `5dd`, `10k`).
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
270pub struct CountedAction {
271    pub count: u32,
272    pub action: Action,
273}
274
275impl CountedAction {
276    #[must_use]
277    pub fn once(action: Action) -> Self {
278        Self { count: 1, action }
279    }
280
281    #[must_use]
282    pub fn repeated(count: u32, action: Action) -> Self {
283        Self {
284            count: count.max(1),
285            action,
286        }
287    }
288}
289
290/// Whether an action can change buffer TEXT.
291///
292/// This exists so that anything cached against the buffer's contents — today
293/// the search-match set, tomorrow anything else derived from it — is
294/// invalidated by construction rather than by remembering to. Search
295/// highlights were stale after every edit precisely because that invalidation
296/// was a thing to remember: `SearchState::refresh` existed and had zero
297/// callers, so inserting four characters repainted the highlight four columns
298/// off.
299#[derive(Clone, Copy, Debug, PartialEq, Eq)]
300pub enum TextEffect {
301    /// May edit the active buffer. Derived state must be recomputed.
302    Mutates,
303    /// Cannot edit the active buffer.
304    Preserves,
305}
306
307impl Action {
308    /// Classify this action's effect on buffer text.
309    ///
310    /// **Total over `Action` — no wildcard arm.** A new variant fails to
311    /// compile here rather than silently defaulting to "preserves", which is
312    /// the direction that produces a stale-cache bug rather than a slow one.
313    ///
314    /// Deliberately CONSERVATIVE where a variant's reach is open-ended:
315    /// `Command`/`SubmitCommand` can run an ex-command that edits. It is not
316    /// conservative for `Backspace`/`DeleteForward` — those genuinely edit the
317    /// buffer outside a prompt. This paragraph claimed they did for months
318    /// before the Insert-mode arm was written; the classifier was right about
319    /// the design and the executor had simply never implemented it.
320    /// Over-reporting costs one extra scan; under-reporting paints the wrong
321    /// columns, so the asymmetry decides the genuinely doubtful cases.
322    #[must_use]
323    pub const fn text_effect(&self) -> TextEffect {
324        match self {
325            Self::Edit(_)
326            | Self::InsertChar(_)
327            | Self::ApplyOperator { .. }
328            | Self::ApplyOperatorObject { .. }
329            | Self::Undo
330            | Self::Redo
331            | Self::Backspace
332            | Self::DeleteForward
333            | Self::DeleteWordBefore
334            | Self::DeleteToLineStart
335            | Self::TextObject(_)
336            | Self::Command { .. }
337            | Self::SearchSubmitOperated { .. }
338            | Self::RepeatLastChange
339            | Self::SubmitCommand => TextEffect::Mutates,
340
341            // `o`/`O` add a line; `i`/`I`/`a`/`A` move the caret and nothing
342            // else. Classified from the payload rather than reported as
343            // "Mutates" wholesale: over-reporting here costs a re-scan of the
344            // match set on every `i`, which is the most-pressed key in the
345            // editor.
346            Self::EnterInsert(at) => {
347                if at.opens_a_line() {
348                    TextEffect::Mutates
349                } else {
350                    TextEffect::Preserves
351                }
352            }
353
354            Self::Move(_)
355            | Self::Operator(_)
356            | Self::ChangeMode(_)
357            | Self::Save
358            | Self::Quit
359            | Self::SearchOpen(_)
360            | Self::SearchRepeat { .. }
361            | Self::SearchWord { .. }
362            | Self::ClearSearchHighlight
363            | Self::JumpBack
364            | Self::JumpForward
365            | Self::PromptCaret { .. }
366            | Self::PromptHistory { .. }
367            | Self::SearchPreviewStep { .. }
368            | Self::Pending => TextEffect::Preserves,
369        }
370    }
371}
372
373/// What an action does to search HIGHLIGHTING.
374///
375/// vim leaves `hlsearch` lit until `:nohlsearch`, which is why nearly every
376/// published vimrc remaps something to `:noh` — the highlight has done its job
377/// the moment you start editing, and leaving it on turns the buffer into
378/// confetti. escriba clears it on the first action that is plainly not part of
379/// searching.
380///
381/// Clearing SUPPRESSES without forgetting: the pattern survives, so `n` still
382/// works and re-lights.
383#[derive(Clone, Copy, Debug, PartialEq, Eq)]
384pub enum HighlightEffect {
385    /// Search highlighting stays as it is.
386    Keep,
387    /// Stop drawing highlights (the pattern is retained).
388    Clear,
389}
390
391impl Action {
392    /// Classify this action's effect on search highlighting.
393    ///
394    /// **Total over `Action` — no wildcard arm.** "We forgot to clear on the
395    /// new command" becomes unconstructible rather than remembered: adding a
396    /// variant forces the decision here.
397    #[must_use]
398    pub const fn highlight_effect(&self) -> HighlightEffect {
399        match self {
400            // Everything that IS searching, or that is operating the prompt.
401            Self::SearchOpen(_)
402            | Self::SearchRepeat { .. }
403            | Self::SearchWord { .. }
404            | Self::SearchSubmitOperated { .. }
405            | Self::ClearSearchHighlight
406            | Self::TextObject(_)
407            | Self::SearchPreviewStep { .. }
408            | Self::PromptHistory { .. }
409            | Self::Backspace
410            | Self::PromptCaret { .. }
411            | Self::DeleteForward
412            | Self::DeleteWordBefore
413            | Self::DeleteToLineStart
414            | Self::InsertChar(_)
415            | Self::SubmitCommand
416            | Self::Pending
417            // A jump is how you USE the matches; extinguishing them mid-walk
418            // would defeat the purpose.
419            | Self::JumpBack
420            | Self::JumpForward
421            // Arming an operator is not yet a move — `d` then `n` must still
422            // see its matches.
423            | Self::Operator(_)
424            | Self::Save
425            | Self::Quit => HighlightEffect::Keep,
426
427            // A search MOTION is searching, not moving on — `n` must not
428            // extinguish the matches it is walking. Every other motion is a
429            // departure.
430            //
431            // The `_` here is deliberate and is the SAFE direction, unlike
432            // `text_effect`'s: a motion nobody has classified yet is "moving
433            // on", which at worst clears a highlight early. The opposite
434            // default would leave stale confetti on screen.
435            // Entering Insert begins editing, so the search is over. Every
436            // OTHER mode change is navigation or a CANCEL — and a cancel must
437            // not erase the committed pattern's highlights. Both
438            // `SearchState::cancel` and the runtime's own `ChangeMode` arm
439            // promise that in writing ("cancelling a new search must not erase
440            // the old highlights"), and a blanket `Clear` here landed on top of
441            // the cancel it had just performed: `/foo<CR>` then `/bar<Esc>`
442            // silently extinguished `foo`.
443            //
444            // Total over `Mode`, so a new mode must decide.
445            Self::ChangeMode(m) => match m {
446                Mode::Insert => HighlightEffect::Clear,
447                Mode::Normal | Mode::Visual | Mode::VisualLine | Mode::Command => {
448                    HighlightEffect::Keep
449                }
450            },
451
452            // Every member of the family begins editing, so the search is
453            // over — the same reading as `ChangeMode(Insert)` above, and it
454            // must not drift from it.
455            Self::EnterInsert(_) => HighlightEffect::Clear,
456
457            Self::Move(m) => match m {
458                Motion::SearchNext | Motion::SearchPrev => HighlightEffect::Keep,
459                _ => HighlightEffect::Clear,
460            },
461
462            // Moving on, or changing the text: the search is over.
463            Self::ApplyOperator { .. }
464            | Self::ApplyOperatorObject { .. }
465            | Self::RepeatLastChange
466            | Self::Edit(_)
467            | Self::Command { .. }
468            | Self::Undo
469            | Self::Redo => HighlightEffect::Clear,
470        }
471    }
472}
473
474impl Action {
475    /// Does this action edit or navigate an OPEN PROMPT, rather than doing
476    /// something to the buffer?
477    ///
478    /// The operator-pending machine needs this: during `d/foo` the operator
479    /// must survive every keystroke that is part of composing the pattern, and
480    /// disarm on anything that is not.
481    ///
482    /// **Total over `Action` — no wildcard arm**, and that totality is the
483    /// whole point. The machine originally listed the prompt actions inline;
484    /// when `PromptCaret`, `DeleteForward`, `DeleteWordBefore`,
485    /// `DeleteToLineStart` and `SearchPreviewStep` were added later, none was
486    /// added to that list, so pressing `←` or `<C-g>` midway through `d/foo`
487    /// silently disarmed the operator — reintroducing exactly the defect the
488    /// `AwaitingSearch` state had been created to fix. A new prompt action now
489    /// cannot be added without deciding here.
490    #[must_use]
491    pub const fn edits_prompt(&self) -> bool {
492        match self {
493            Self::InsertChar(_)
494            | Self::Backspace
495            | Self::PromptHistory { .. }
496            | Self::PromptCaret { .. }
497            | Self::DeleteForward
498            | Self::DeleteWordBefore
499            | Self::DeleteToLineStart
500            | Self::SearchPreviewStep { .. } => true,
501
502            Self::Move(_)
503            | Self::Operator(_)
504            | Self::ApplyOperator { .. }
505            | Self::ApplyOperatorObject { .. }
506            | Self::TextObject(_)
507            | Self::Edit(_)
508            | Self::ChangeMode(_)
509            // Insert-entry acts on the BUFFER, never on an open prompt — and
510            // it is unreachable while one is open anyway, because Command mode
511            // preempts every printable key before the table is consulted.
512            | Self::EnterInsert(_)
513            | Self::Command { .. }
514            | Self::SubmitCommand
515            | Self::Undo
516            | Self::Redo
517            | Self::Save
518            | Self::Quit
519            | Self::SearchOpen(_)
520            | Self::SearchRepeat { .. }
521            | Self::SearchWord { .. }
522            | Self::SearchSubmitOperated { .. }
523            | Self::ClearSearchHighlight
524            | Self::RepeatLastChange
525            | Self::JumpBack
526            | Self::JumpForward
527            | Self::Pending => false,
528        }
529    }
530}