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