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