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 /// Up/Down inside a prompt — walk search history.
99 ///
100 /// `back = true` is older. Stepping forward past the newest entry restores
101 /// the text that was being typed when browsing began, so arrowing through
102 /// history and back never destroys a half-typed pattern.
103 PromptHistory {
104 back: bool,
105 },
106 /// No-op — used when a key sequence is pending but not yet complete.
107 Pending,
108}
109
110/// An [`Action`] with an optional repetition count (vim's `5dd`, `10k`).
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
112pub struct CountedAction {
113 pub count: u32,
114 pub action: Action,
115}
116
117impl CountedAction {
118 #[must_use]
119 pub fn once(action: Action) -> Self {
120 Self { count: 1, action }
121 }
122
123 #[must_use]
124 pub fn repeated(count: u32, action: Action) -> Self {
125 Self {
126 count: count.max(1),
127 action,
128 }
129 }
130}
131
132/// Whether an action can change buffer TEXT.
133///
134/// This exists so that anything cached against the buffer's contents — today
135/// the search-match set, tomorrow anything else derived from it — is
136/// invalidated by construction rather than by remembering to. Search
137/// highlights were stale after every edit precisely because that invalidation
138/// was a thing to remember: `SearchState::refresh` existed and had zero
139/// callers, so inserting four characters repainted the highlight four columns
140/// off.
141#[derive(Clone, Copy, Debug, PartialEq, Eq)]
142pub enum TextEffect {
143 /// May edit the active buffer. Derived state must be recomputed.
144 Mutates,
145 /// Cannot edit the active buffer.
146 Preserves,
147}
148
149impl Action {
150 /// Classify this action's effect on buffer text.
151 ///
152 /// **Total over `Action` — no wildcard arm.** A new variant fails to
153 /// compile here rather than silently defaulting to "preserves", which is
154 /// the direction that produces a stale-cache bug rather than a slow one.
155 ///
156 /// Deliberately CONSERVATIVE where a variant's reach is open-ended:
157 /// `Command`/`SubmitCommand` can run an ex-command that edits, and the
158 /// keymap's `PromptBackspace` doubles as the buffer Backspace outside a
159 /// prompt. Over-reporting costs one extra scan; under-reporting paints
160 /// the wrong columns, so the asymmetry decides the doubtful cases.
161 #[must_use]
162 pub const fn text_effect(&self) -> TextEffect {
163 match self {
164 Self::Edit(_)
165 | Self::InsertChar(_)
166 | Self::ApplyOperator { .. }
167 | Self::Undo
168 | Self::Redo
169 | Self::PromptBackspace
170 | Self::Command { .. }
171 | Self::SearchSubmitOperated { .. }
172 | Self::SubmitCommand => TextEffect::Mutates,
173
174 Self::Move(_)
175 | Self::Operator(_)
176 | Self::ChangeMode(_)
177 | Self::Save
178 | Self::Quit
179 | Self::SearchOpen(_)
180 | Self::SearchRepeat { .. }
181 | Self::SearchWord { .. }
182 | Self::ClearSearchHighlight
183 | Self::JumpBack
184 | Self::JumpForward
185 | Self::PromptHistory { .. }
186 | Self::Pending => TextEffect::Preserves,
187 }
188 }
189}
190
191/// What an action does to search HIGHLIGHTING.
192///
193/// vim leaves `hlsearch` lit until `:nohlsearch`, which is why nearly every
194/// published vimrc remaps something to `:noh` — the highlight has done its job
195/// the moment you start editing, and leaving it on turns the buffer into
196/// confetti. escriba clears it on the first action that is plainly not part of
197/// searching.
198///
199/// Clearing SUPPRESSES without forgetting: the pattern survives, so `n` still
200/// works and re-lights.
201#[derive(Clone, Copy, Debug, PartialEq, Eq)]
202pub enum HighlightEffect {
203 /// Search highlighting stays as it is.
204 Keep,
205 /// Stop drawing highlights (the pattern is retained).
206 Clear,
207}
208
209impl Action {
210 /// Classify this action's effect on search highlighting.
211 ///
212 /// **Total over `Action` — no wildcard arm.** "We forgot to clear on the
213 /// new command" becomes unconstructible rather than remembered: adding a
214 /// variant forces the decision here.
215 #[must_use]
216 pub const fn highlight_effect(&self) -> HighlightEffect {
217 match self {
218 // Everything that IS searching, or that is operating the prompt.
219 Self::SearchOpen(_)
220 | Self::SearchRepeat { .. }
221 | Self::SearchWord { .. }
222 | Self::SearchSubmitOperated { .. }
223 | Self::ClearSearchHighlight
224 | Self::PromptHistory { .. }
225 | Self::PromptBackspace
226 | Self::InsertChar(_)
227 | Self::SubmitCommand
228 | Self::Pending
229 // A jump is how you USE the matches; extinguishing them mid-walk
230 // would defeat the purpose.
231 | Self::JumpBack
232 | Self::JumpForward
233 // Arming an operator is not yet a move — `d` then `n` must still
234 // see its matches.
235 | Self::Operator(_)
236 | Self::Save
237 | Self::Quit => HighlightEffect::Keep,
238
239 // A search MOTION is searching, not moving on — `n` must not
240 // extinguish the matches it is walking. Every other motion is a
241 // departure.
242 //
243 // The `_` here is deliberate and is the SAFE direction, unlike
244 // `text_effect`'s: a motion nobody has classified yet is "moving
245 // on", which at worst clears a highlight early. The opposite
246 // default would leave stale confetti on screen.
247 Self::Move(m) => match m {
248 Motion::SearchNext | Motion::SearchPrev => HighlightEffect::Keep,
249 _ => HighlightEffect::Clear,
250 },
251
252 // Moving on, or changing the text: the search is over.
253 Self::ApplyOperator { .. }
254 | Self::Edit(_)
255 | Self::ChangeMode(_)
256 | Self::Command { .. }
257 | Self::Undo
258 | Self::Redo => HighlightEffect::Clear,
259 }
260 }
261}