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 prompt caret.
158 PromptDeleteWord,
159 /// `<C-u>` — delete from the prompt caret back to the start.
160 PromptClearToStart,
161 /// Up/Down inside a prompt — walk search history.
162 ///
163 /// `back = true` is older. Stepping forward past the newest entry restores
164 /// the text that was being typed when browsing began, so arrowing through
165 /// history and back never destroys a half-typed pattern.
166 PromptHistory {
167 back: bool,
168 },
169 /// No-op — used when a key sequence is pending but not yet complete.
170 Pending,
171}
172
173/// An [`Action`] with an optional repetition count (vim's `5dd`, `10k`).
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
175pub struct CountedAction {
176 pub count: u32,
177 pub action: Action,
178}
179
180impl CountedAction {
181 #[must_use]
182 pub fn once(action: Action) -> Self {
183 Self { count: 1, action }
184 }
185
186 #[must_use]
187 pub fn repeated(count: u32, action: Action) -> Self {
188 Self {
189 count: count.max(1),
190 action,
191 }
192 }
193}
194
195/// Whether an action can change buffer TEXT.
196///
197/// This exists so that anything cached against the buffer's contents — today
198/// the search-match set, tomorrow anything else derived from it — is
199/// invalidated by construction rather than by remembering to. Search
200/// highlights were stale after every edit precisely because that invalidation
201/// was a thing to remember: `SearchState::refresh` existed and had zero
202/// callers, so inserting four characters repainted the highlight four columns
203/// off.
204#[derive(Clone, Copy, Debug, PartialEq, Eq)]
205pub enum TextEffect {
206 /// May edit the active buffer. Derived state must be recomputed.
207 Mutates,
208 /// Cannot edit the active buffer.
209 Preserves,
210}
211
212impl Action {
213 /// Classify this action's effect on buffer text.
214 ///
215 /// **Total over `Action` — no wildcard arm.** A new variant fails to
216 /// compile here rather than silently defaulting to "preserves", which is
217 /// the direction that produces a stale-cache bug rather than a slow one.
218 ///
219 /// Deliberately CONSERVATIVE where a variant's reach is open-ended:
220 /// `Command`/`SubmitCommand` can run an ex-command that edits. It is not
221 /// conservative for `Backspace`/`DeleteForward` — those genuinely edit the
222 /// buffer outside a prompt. This paragraph claimed they did for months
223 /// before the Insert-mode arm was written; the classifier was right about
224 /// the design and the executor had simply never implemented it.
225 /// Over-reporting costs one extra scan; under-reporting paints the wrong
226 /// columns, so the asymmetry decides the genuinely doubtful cases.
227 #[must_use]
228 pub const fn text_effect(&self) -> TextEffect {
229 match self {
230 Self::Edit(_)
231 | Self::InsertChar(_)
232 | Self::ApplyOperator { .. }
233 | Self::ApplyOperatorObject { .. }
234 | Self::Undo
235 | Self::Redo
236 | Self::Backspace
237 | Self::DeleteForward
238 | Self::PromptDeleteWord
239 | Self::PromptClearToStart
240 | Self::TextObject(_)
241 | Self::Command { .. }
242 | Self::SearchSubmitOperated { .. }
243 | Self::RepeatLastChange
244 | Self::SubmitCommand => TextEffect::Mutates,
245
246 Self::Move(_)
247 | Self::Operator(_)
248 | Self::ChangeMode(_)
249 | Self::Save
250 | Self::Quit
251 | Self::SearchOpen(_)
252 | Self::SearchRepeat { .. }
253 | Self::SearchWord { .. }
254 | Self::ClearSearchHighlight
255 | Self::JumpBack
256 | Self::JumpForward
257 | Self::PromptCaret { .. }
258 | Self::PromptHistory { .. }
259 | Self::SearchPreviewStep { .. }
260 | Self::Pending => TextEffect::Preserves,
261 }
262 }
263}
264
265/// What an action does to search HIGHLIGHTING.
266///
267/// vim leaves `hlsearch` lit until `:nohlsearch`, which is why nearly every
268/// published vimrc remaps something to `:noh` — the highlight has done its job
269/// the moment you start editing, and leaving it on turns the buffer into
270/// confetti. escriba clears it on the first action that is plainly not part of
271/// searching.
272///
273/// Clearing SUPPRESSES without forgetting: the pattern survives, so `n` still
274/// works and re-lights.
275#[derive(Clone, Copy, Debug, PartialEq, Eq)]
276pub enum HighlightEffect {
277 /// Search highlighting stays as it is.
278 Keep,
279 /// Stop drawing highlights (the pattern is retained).
280 Clear,
281}
282
283impl Action {
284 /// Classify this action's effect on search highlighting.
285 ///
286 /// **Total over `Action` — no wildcard arm.** "We forgot to clear on the
287 /// new command" becomes unconstructible rather than remembered: adding a
288 /// variant forces the decision here.
289 #[must_use]
290 pub const fn highlight_effect(&self) -> HighlightEffect {
291 match self {
292 // Everything that IS searching, or that is operating the prompt.
293 Self::SearchOpen(_)
294 | Self::SearchRepeat { .. }
295 | Self::SearchWord { .. }
296 | Self::SearchSubmitOperated { .. }
297 | Self::ClearSearchHighlight
298 | Self::TextObject(_)
299 | Self::SearchPreviewStep { .. }
300 | Self::PromptHistory { .. }
301 | Self::Backspace
302 | Self::PromptCaret { .. }
303 | Self::DeleteForward
304 | Self::PromptDeleteWord
305 | Self::PromptClearToStart
306 | Self::InsertChar(_)
307 | Self::SubmitCommand
308 | Self::Pending
309 // A jump is how you USE the matches; extinguishing them mid-walk
310 // would defeat the purpose.
311 | Self::JumpBack
312 | Self::JumpForward
313 // Arming an operator is not yet a move — `d` then `n` must still
314 // see its matches.
315 | Self::Operator(_)
316 | Self::Save
317 | Self::Quit => HighlightEffect::Keep,
318
319 // A search MOTION is searching, not moving on — `n` must not
320 // extinguish the matches it is walking. Every other motion is a
321 // departure.
322 //
323 // The `_` here is deliberate and is the SAFE direction, unlike
324 // `text_effect`'s: a motion nobody has classified yet is "moving
325 // on", which at worst clears a highlight early. The opposite
326 // default would leave stale confetti on screen.
327 // Entering Insert begins editing, so the search is over. Every
328 // OTHER mode change is navigation or a CANCEL — and a cancel must
329 // not erase the committed pattern's highlights. Both
330 // `SearchState::cancel` and the runtime's own `ChangeMode` arm
331 // promise that in writing ("cancelling a new search must not erase
332 // the old highlights"), and a blanket `Clear` here landed on top of
333 // the cancel it had just performed: `/foo<CR>` then `/bar<Esc>`
334 // silently extinguished `foo`.
335 //
336 // Total over `Mode`, so a new mode must decide.
337 Self::ChangeMode(m) => match m {
338 Mode::Insert => HighlightEffect::Clear,
339 Mode::Normal | Mode::Visual | Mode::VisualLine | Mode::Command => {
340 HighlightEffect::Keep
341 }
342 },
343
344 Self::Move(m) => match m {
345 Motion::SearchNext | Motion::SearchPrev => HighlightEffect::Keep,
346 _ => HighlightEffect::Clear,
347 },
348
349 // Moving on, or changing the text: the search is over.
350 Self::ApplyOperator { .. }
351 | Self::ApplyOperatorObject { .. }
352 | Self::RepeatLastChange
353 | Self::Edit(_)
354 | Self::Command { .. }
355 | Self::Undo
356 | Self::Redo => HighlightEffect::Clear,
357 }
358 }
359}
360
361impl Action {
362 /// Does this action edit or navigate an OPEN PROMPT, rather than doing
363 /// something to the buffer?
364 ///
365 /// The operator-pending machine needs this: during `d/foo` the operator
366 /// must survive every keystroke that is part of composing the pattern, and
367 /// disarm on anything that is not.
368 ///
369 /// **Total over `Action` — no wildcard arm**, and that totality is the
370 /// whole point. The machine originally listed the prompt actions inline;
371 /// when `PromptCaret`, `DeleteForward`, `PromptDeleteWord`,
372 /// `PromptClearToStart` and `SearchPreviewStep` were added later, none was
373 /// added to that list, so pressing `←` or `<C-g>` midway through `d/foo`
374 /// silently disarmed the operator — reintroducing exactly the defect the
375 /// `AwaitingSearch` state had been created to fix. A new prompt action now
376 /// cannot be added without deciding here.
377 #[must_use]
378 pub const fn edits_prompt(&self) -> bool {
379 match self {
380 Self::InsertChar(_)
381 | Self::Backspace
382 | Self::PromptHistory { .. }
383 | Self::PromptCaret { .. }
384 | Self::DeleteForward
385 | Self::PromptDeleteWord
386 | Self::PromptClearToStart
387 | Self::SearchPreviewStep { .. } => true,
388
389 Self::Move(_)
390 | Self::Operator(_)
391 | Self::ApplyOperator { .. }
392 | Self::ApplyOperatorObject { .. }
393 | Self::TextObject(_)
394 | Self::Edit(_)
395 | Self::ChangeMode(_)
396 | Self::Command { .. }
397 | Self::SubmitCommand
398 | Self::Undo
399 | Self::Redo
400 | Self::Save
401 | Self::Quit
402 | Self::SearchOpen(_)
403 | Self::SearchRepeat { .. }
404 | Self::SearchWord { .. }
405 | Self::SearchSubmitOperated { .. }
406 | Self::ClearSearchHighlight
407 | Self::RepeatLastChange
408 | Self::JumpBack
409 | Self::JumpForward
410 | Self::Pending => false,
411 }
412 }
413}