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::{Motion, Operator};
8
9/// A fully-resolved editor action — what the keymap emits, what the buffer
10/// consumes.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
12pub enum Action {
13 /// Move every cursor by `motion`.
14 Move(Motion),
15 /// Begin an operator (the `d`/`c`/`y` key). The editor enters
16 /// operator-pending: the next motion composes into an [`Action::ApplyOperator`].
17 /// Resolved by the operator-pending FSM, never executed directly.
18 Operator(Operator),
19 /// Apply a pending operator over a motion (delete-word, yank-line, etc.).
20 ApplyOperator {
21 op: Operator,
22 motion: Motion,
23 },
24 /// Apply a primitive edit at each cursor.
25 Edit(Edit),
26 /// Enter the given mode.
27 ChangeMode(Mode),
28 /// Run a named command (via the command registry).
29 Command {
30 name: String,
31 args: Vec<String>,
32 },
33 /// Insert a character at each caret. Separate from Edit so the keymap
34 /// can stay ignorant of rope details.
35 InsertChar(char),
36 /// Submit a minibuffer / command-mode line (e.g. `:w`, `:q`).
37 SubmitCommand,
38 /// Undo / redo one change.
39 Undo,
40 Redo,
41 /// Save the current buffer.
42 Save,
43 /// Quit the editor.
44 Quit,
45 // ── search (vim `/`, `?`, `n`, `N`, `*`, `#`) ──────────────────────
46 /// Open the search prompt in `direction` (the `/` and `?` keys).
47 ///
48 /// The prompt reuses `Mode::Command` rather than adding a mode variant:
49 /// vim's `/` IS the command-line with a different prompt character, and
50 /// this module's own doc states new modes are layered through pending
51 /// state, not new variants. `SearchState`'s typed `Option<Prompt>` is what
52 /// disambiguates a `<CR>` that submits a search from one that submits an
53 /// ex-command — a discriminator that cannot be forgotten, unlike a bool.
54 SearchOpen(SearchDirection),
55 /// `n` (`reverse = false`) / `N` (`reverse = true`) — jump to the next
56 /// match, relative to the direction the search was committed with, so `N`
57 /// after a `?` search moves forward.
58 SearchRepeat {
59 reverse: bool,
60 },
61 /// `*` (`reverse = false`) / `#` (`reverse = true`) — search the whole word
62 /// under the cursor. Literal, not regex: the word may contain `.` or `[`
63 /// and the user means those characters.
64 SearchWord {
65 reverse: bool,
66 },
67 /// `:noh` — stop highlighting matches while keeping the pattern, so `n`
68 /// still works. Distinct from cancelling a search.
69 ClearSearchHighlight,
70
71 /// `d/foo<CR>` — commit the open search prompt and apply `op` from the
72 /// prompt's ORIGIN to wherever the search lands.
73 ///
74 /// Emitted only by the operator-pending machine; no keymap produces it.
75 /// It exists because committing a search MOVES the cursor, and the
76 /// operator needs the pre-move position as its start point. Carrying the
77 /// operator through the commit makes "operate over a search" one atomic
78 /// action instead of two steps racing to own the cursor.
79 SearchSubmitOperated {
80 op: Operator,
81 },
82
83 /// `<C-o>` — walk back to where the last far jump was taken from.
84 ///
85 /// Lives beside the search actions because search is what made it
86 /// necessary — committing a `/` used to be a one-way door — but it is not
87 /// a search action: `G`, `gg`, `%` and tag jumps are the other consumers.
88 JumpBack,
89 /// `<C-i>` — walk forward again after [`Action::JumpBack`].
90 JumpForward,
91 /// Backspace inside a command-line or search prompt.
92 ///
93 /// Key::Backspace was previously bound in NO mode, so the minibuffer could
94 /// be typed into but never corrected — a typo meant Esc and start again.
95 /// One action serves both prompts; the runtime routes it by whether a
96 /// search prompt is open.
97 PromptBackspace,
98
99 /// Move the caret inside an open prompt (`←` `→` `Home` `End`).
100 ///
101 /// The prompt was append-only until this existed, so a typo in the middle
102 /// of a pattern could only be fixed by deleting back to it.
103 PromptCaret {
104 to: escriba_search::CaretMove,
105 },
106 /// `<Del>` — delete the character AT the prompt caret.
107 PromptDelete,
108 /// `<C-w>` — delete the word before the prompt caret.
109 PromptDeleteWord,
110 /// `<C-u>` — delete from the prompt caret back to the start.
111 PromptClearToStart,
112 /// Up/Down inside a prompt — walk search history.
113 ///
114 /// `back = true` is older. Stepping forward past the newest entry restores
115 /// the text that was being typed when browsing began, so arrowing through
116 /// history and back never destroys a half-typed pattern.
117 PromptHistory {
118 back: bool,
119 },
120 /// No-op — used when a key sequence is pending but not yet complete.
121 Pending,
122}
123
124/// An [`Action`] with an optional repetition count (vim's `5dd`, `10k`).
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
126pub struct CountedAction {
127 pub count: u32,
128 pub action: Action,
129}
130
131impl CountedAction {
132 #[must_use]
133 pub fn once(action: Action) -> Self {
134 Self { count: 1, action }
135 }
136
137 #[must_use]
138 pub fn repeated(count: u32, action: Action) -> Self {
139 Self {
140 count: count.max(1),
141 action,
142 }
143 }
144}
145
146/// Whether an action can change buffer TEXT.
147///
148/// This exists so that anything cached against the buffer's contents — today
149/// the search-match set, tomorrow anything else derived from it — is
150/// invalidated by construction rather than by remembering to. Search
151/// highlights were stale after every edit precisely because that invalidation
152/// was a thing to remember: `SearchState::refresh` existed and had zero
153/// callers, so inserting four characters repainted the highlight four columns
154/// off.
155#[derive(Clone, Copy, Debug, PartialEq, Eq)]
156pub enum TextEffect {
157 /// May edit the active buffer. Derived state must be recomputed.
158 Mutates,
159 /// Cannot edit the active buffer.
160 Preserves,
161}
162
163impl Action {
164 /// Classify this action's effect on buffer text.
165 ///
166 /// **Total over `Action` — no wildcard arm.** A new variant fails to
167 /// compile here rather than silently defaulting to "preserves", which is
168 /// the direction that produces a stale-cache bug rather than a slow one.
169 ///
170 /// Deliberately CONSERVATIVE where a variant's reach is open-ended:
171 /// `Command`/`SubmitCommand` can run an ex-command that edits, and the
172 /// keymap's `PromptBackspace` doubles as the buffer Backspace outside a
173 /// prompt. Over-reporting costs one extra scan; under-reporting paints
174 /// the wrong columns, so the asymmetry decides the doubtful cases.
175 #[must_use]
176 pub const fn text_effect(&self) -> TextEffect {
177 match self {
178 Self::Edit(_)
179 | Self::InsertChar(_)
180 | Self::ApplyOperator { .. }
181 | Self::Undo
182 | Self::Redo
183 | Self::PromptBackspace
184 | Self::PromptDelete
185 | Self::PromptDeleteWord
186 | Self::PromptClearToStart
187 | Self::Command { .. }
188 | Self::SearchSubmitOperated { .. }
189 | Self::SubmitCommand => TextEffect::Mutates,
190
191 Self::Move(_)
192 | Self::Operator(_)
193 | Self::ChangeMode(_)
194 | Self::Save
195 | Self::Quit
196 | Self::SearchOpen(_)
197 | Self::SearchRepeat { .. }
198 | Self::SearchWord { .. }
199 | Self::ClearSearchHighlight
200 | Self::JumpBack
201 | Self::JumpForward
202 | Self::PromptCaret { .. }
203 | Self::PromptHistory { .. }
204 | Self::Pending => TextEffect::Preserves,
205 }
206 }
207}
208
209/// What an action does to search HIGHLIGHTING.
210///
211/// vim leaves `hlsearch` lit until `:nohlsearch`, which is why nearly every
212/// published vimrc remaps something to `:noh` — the highlight has done its job
213/// the moment you start editing, and leaving it on turns the buffer into
214/// confetti. escriba clears it on the first action that is plainly not part of
215/// searching.
216///
217/// Clearing SUPPRESSES without forgetting: the pattern survives, so `n` still
218/// works and re-lights.
219#[derive(Clone, Copy, Debug, PartialEq, Eq)]
220pub enum HighlightEffect {
221 /// Search highlighting stays as it is.
222 Keep,
223 /// Stop drawing highlights (the pattern is retained).
224 Clear,
225}
226
227impl Action {
228 /// Classify this action's effect on search highlighting.
229 ///
230 /// **Total over `Action` — no wildcard arm.** "We forgot to clear on the
231 /// new command" becomes unconstructible rather than remembered: adding a
232 /// variant forces the decision here.
233 #[must_use]
234 pub const fn highlight_effect(&self) -> HighlightEffect {
235 match self {
236 // Everything that IS searching, or that is operating the prompt.
237 Self::SearchOpen(_)
238 | Self::SearchRepeat { .. }
239 | Self::SearchWord { .. }
240 | Self::SearchSubmitOperated { .. }
241 | Self::ClearSearchHighlight
242 | Self::PromptHistory { .. }
243 | Self::PromptBackspace
244 | Self::PromptCaret { .. }
245 | Self::PromptDelete
246 | Self::PromptDeleteWord
247 | Self::PromptClearToStart
248 | Self::InsertChar(_)
249 | Self::SubmitCommand
250 | Self::Pending
251 // A jump is how you USE the matches; extinguishing them mid-walk
252 // would defeat the purpose.
253 | Self::JumpBack
254 | Self::JumpForward
255 // Arming an operator is not yet a move — `d` then `n` must still
256 // see its matches.
257 | Self::Operator(_)
258 | Self::Save
259 | Self::Quit => HighlightEffect::Keep,
260
261 // A search MOTION is searching, not moving on — `n` must not
262 // extinguish the matches it is walking. Every other motion is a
263 // departure.
264 //
265 // The `_` here is deliberate and is the SAFE direction, unlike
266 // `text_effect`'s: a motion nobody has classified yet is "moving
267 // on", which at worst clears a highlight early. The opposite
268 // default would leave stale confetti on screen.
269 Self::Move(m) => match m {
270 Motion::SearchNext | Motion::SearchPrev => HighlightEffect::Keep,
271 _ => HighlightEffect::Clear,
272 },
273
274 // Moving on, or changing the text: the search is over.
275 Self::ApplyOperator { .. }
276 | Self::Edit(_)
277 | Self::ChangeMode(_)
278 | Self::Command { .. }
279 | Self::Undo
280 | Self::Redo => HighlightEffect::Clear,
281 }
282 }
283}