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::{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}