escriba_core/motion.rs
1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4/// Cursor motions — primitive movements the keymap compiles user keys to.
5///
6/// Two families:
7/// - **Text motions** — vim-ish char/word/line/doc/page motions.
8/// - **Structural motions** — Lisp-aware `(forward-sexp)` / `(backward-sexp)`
9/// / `(up-list)` / `(down-list)` equivalents. Enabled on buffers whose
10/// major mode opts in via `(defmajor-mode … :structural-lisp #t)`.
11/// Matches paredit's model — equal-or-superior to emacs on Lisp UX.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
13pub enum Motion {
14 // ── Text motions (vim-ish base) ────────────────────────────────
15 Left,
16 Right,
17 Up,
18 Down,
19 WordStartNext,
20 WordEndNext,
21 WordStartPrev,
22 /// `ge` — to the END of the previous word. vim's only backward-inclusive
23 /// motion, and the reason `is_inclusive` cannot simply mean "widen right".
24 WordEndPrev,
25 // ── WORD motions (`W`/`E`/`B`/`gE`) ────────────────────────────
26 //
27 // vim's second word width: whitespace-delimited, so `foo.bar` is ONE
28 // WORD and three words. A separate arm rather than a `Width` field
29 // because every call site that resolves a motion has to decide, and a
30 // field is a decision a `match` arm cannot forget to make.
31 BigWordStartNext,
32 BigWordEndNext,
33 BigWordStartPrev,
34 BigWordEndPrev,
35 LineStart,
36 LineFirstNonBlank,
37 /// `g_` — the LAST non-blank on the line. Inclusive, unlike `$`.
38 LineLastNonBlank,
39 LineEnd,
40 /// `|` — to a 1-based screen column on the current line.
41 Column(u32),
42 /// `+` / `<CR>` — first non-blank of the next line.
43 LineDownFirstNonBlank,
44 /// `-` — first non-blank of the previous line.
45 LineUpFirstNonBlank,
46 DocStart,
47 DocEnd,
48 // ── character search (`f` / `F` / `t` / `T`, and `;` / `,`) ─────
49 /// `f{c}` (`backward=false, till=false`), `t{c}` (`till=true`),
50 /// `F{c}` / `T{c}` (`backward=true`). The character is carried IN the
51 /// motion so `df(` is one composed `ApplyOperator` like every other
52 /// operated motion — a separate "pending char" the operator had to read
53 /// would be a second composition mechanism beside the FSM.
54 FindChar {
55 ch: char,
56 backward: bool,
57 till: bool,
58 },
59 /// `;` (`reverse=false`) / `,` (`reverse=true`) — repeat the last
60 /// [`Motion::FindChar`]. Resolved against runtime state, so like
61 /// [`Motion::SearchNext`] the enum stays a pure description.
62 RepeatFind {
63 reverse: bool,
64 },
65 /// `%` — to the match of the bracket under (or next on) the cursor.
66 MatchPair,
67 // ── marks (`m` sets, `` ` `` and `'` jump) ─────────────────────
68 /// `` `{a-z} `` — to a mark's exact line AND column.
69 MarkExact(char),
70 /// `'{a-z}` — to the first non-blank of a mark's LINE. vim's two spellings
71 /// are two motions, not one motion and a modifier: `` `a `` is exclusive
72 /// and `'a` is linewise, so `d'a` and ``d`a`` delete different things.
73 MarkLine(char),
74 // ── paragraph / sentence ───────────────────────────────────────
75 /// `}` — to the next blank line (paragraph boundary).
76 ParagraphNext,
77 /// `{` — to the previous blank line.
78 ParagraphPrev,
79 /// `)` — to the start of the next sentence.
80 SentenceNext,
81 /// `(` — to the start of the previous sentence.
82 SentencePrev,
83 // ── viewport-relative (`H` / `M` / `L`) ────────────────────────
84 ScreenTop,
85 ScreenMiddle,
86 ScreenBottom,
87 PageUp,
88 PageDown,
89 HalfPageUp,
90 HalfPageDown,
91 GotoLine(u32),
92
93 // ── Structural Lisp motions (paredit-grade) ────────────────────
94 /// Move to the start of the next sibling s-expression.
95 ForwardSexp,
96 /// Move to the start of the previous sibling s-expression.
97 BackwardSexp,
98 /// Move up one parenthesis level — to the opening `(` of the enclosing list.
99 UpList,
100 /// Move down into the current list — past the opening `(`.
101 DownList,
102 /// Move to the start of the enclosing top-level defun / top form.
103 BeginningOfDefun,
104 /// Move to the end of the enclosing top-level defun / top form.
105 EndOfDefun,
106 /// Move to the start of the current s-expression (current atom / list open).
107 BeginningOfSexp,
108 /// Move to the end of the current s-expression (matching close).
109 EndOfSexp,
110
111 // ── search motions ────────────────────────────────────────────────
112 /// To the next search match — vim's `n` used as a MOTION, which is what
113 /// makes `d/foo<CR>`, `dn` and `y*` work. Search being a motion rather
114 /// than a bare cursor jump is the difference between a search box and vim
115 /// search; resolving it needs the committed `SearchState`, so the executor
116 /// supplies it — the enum stays a pure description, like every other arm.
117 SearchNext,
118 /// To the previous search match (vim's `N` as a motion).
119 SearchPrev,
120}
121
122impl Motion {
123 /// Does this motion name a character to ACT ON, rather than a boundary to
124 /// stop before?
125 ///
126 /// vim's exclusive/inclusive split, and it is not cosmetic: `dw` deletes
127 /// up to the next word and `de` deletes *through* the current one. An
128 /// operator range is `[cursor, target)`, so an inclusive motion's target
129 /// has to be widened by one character or the operator leaves the last
130 /// character behind — off by exactly one, on the key most likely to be
131 /// used to delete a word without its trailing space.
132 ///
133 /// `WordEndNext` is the only inclusive motion escriba has today. `f`/`t`/
134 /// `%` are the others in vim and are not bound yet; each lands here when
135 /// it does, which is the point of asking the MOTION rather than
136 /// special-casing `e` at the operator.
137 #[must_use]
138 /// `RepeatFind` is deliberately absent: whether `;` is inclusive depends
139 /// on the direction of the find it repeats, which is runtime state. The
140 /// executor resolves it to the concrete [`Motion::FindChar`] and asks
141 /// THAT — so there is still exactly one rule, applied to a known motion.
142 pub const fn is_inclusive(self) -> bool {
143 matches!(
144 self,
145 Self::WordEndNext
146 | Self::BigWordEndNext
147 | Self::LineLastNonBlank
148 | Self::MatchPair
149 | Self::FindChar {
150 backward: false,
151 ..
152 }
153 )
154 }
155
156 #[must_use]
157 pub const fn is_structural(self) -> bool {
158 matches!(
159 self,
160 Self::ForwardSexp
161 | Self::BackwardSexp
162 | Self::UpList
163 | Self::DownList
164 | Self::BeginningOfDefun
165 | Self::EndOfDefun
166 | Self::BeginningOfSexp
167 | Self::EndOfSexp,
168 )
169 }
170}
171
172/// Operators — vim-style verbs. Combined with a motion they produce an edit.
173///
174/// Structural operators (paredit-grade) compose with structural motions:
175/// - `(slurp-forward)` — pull the next sibling into the current list
176/// - `(barf-forward)` — push the last child out of the current list
177/// - `(splice)` — unwrap the current list (remove parens, keep children)
178/// - `(wrap)` — wrap the target in a new list
179/// - `(raise)` — replace the enclosing list with the current sexp
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
181pub enum Operator {
182 Delete,
183 Yank,
184 Change,
185 Indent,
186 Dedent,
187 Filter,
188 Format,
189 // ── Structural (Lisp-aware) operators ──────────────────────────
190 SlurpForward,
191 SlurpBackward,
192 BarfForward,
193 BarfBackward,
194 Splice,
195 Wrap,
196 Raise,
197}
198
199impl Operator {
200 #[must_use]
201 pub const fn leaves_register(self) -> bool {
202 matches!(self, Self::Delete | Self::Yank | Self::Change)
203 }
204
205 #[must_use]
206 pub const fn is_structural(self) -> bool {
207 matches!(
208 self,
209 Self::SlurpForward
210 | Self::SlurpBackward
211 | Self::BarfForward
212 | Self::BarfBackward
213 | Self::Splice
214 | Self::Wrap
215 | Self::Raise,
216 )
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn register_emitting_ops() {
226 assert!(Operator::Delete.leaves_register());
227 assert!(Operator::Yank.leaves_register());
228 assert!(Operator::Change.leaves_register());
229 assert!(!Operator::Format.leaves_register());
230 }
231}
232
233/// A text EXTENT an operator can act over, as opposed to a point it moves to.
234///
235/// vim's `gn` is the motivating case and shows why the distinction matters:
236/// `dgn` deletes the next match *wherever it is*, including when the cursor is
237/// nowhere near it. Modelled as a motion it would resolve to the match's start
238/// and the operator would act over `[cursor, match.start)` — deleting the text
239/// BEFORE the match instead of the match. Same keys, opposite effect.
240///
241/// Closed, so an unhandled object cannot reach the executor.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
243pub enum TextObject {
244 /// `gn` — the next search match at or after the cursor.
245 NextMatch,
246 /// `gN` — the previous search match at or before the cursor.
247 PrevMatch,
248 /// `dd` / `cc` / `yy` — the current line, LINEWISE.
249 ///
250 /// A doubled operator in vim acts on whole lines, trailing newline
251 /// included, which is why this is an object rather than a motion: there
252 /// is no cursor-to-target range that expresses "this line and its
253 /// terminator" without special-casing the last line.
254 Line,
255 /// `iw` / `aw` — the word under the cursor.
256 ///
257 /// `around: true` takes the trailing run of whitespace as well, which is
258 /// the whole difference vim draws between `diw` and `daw`.
259 Word { around: bool },
260 /// `i(` `a{` `i"` … — the region between a matched pair.
261 ///
262 /// One variant covers brackets and quotes because the only thing that
263 /// differs is whether the delimiters nest; carrying `open`/`close`
264 /// separately lets a quote say `open == close` instead of needing its
265 /// own arm.
266 Delimited {
267 open: char,
268 close: char,
269 around: bool,
270 },
271}
272
273impl TextObject {
274 /// How an operator over this object leaves the register — and therefore
275 /// how a later `p` replays it.
276 ///
277 /// **Total over `TextObject`, no wildcard arm.** The mapping lives here,
278 /// beside the variants, rather than at the one call site that needs it
279 /// today: a new linewise object (vim's `ip`/`ap` paragraph objects are the
280 /// obvious next ones) must decide, and a wildcard would silently answer
281 /// `Charwise` for them — the direction that pastes a paragraph into the
282 /// middle of whatever line the cursor happens to be on.
283 #[must_use]
284 pub const fn register_kind(self) -> crate::register::RegisterKind {
285 use crate::register::RegisterKind as K;
286 match self {
287 Self::Line => K::Linewise,
288 Self::NextMatch | Self::PrevMatch | Self::Word { .. } | Self::Delimited { .. } => {
289 K::Charwise
290 }
291 }
292 }
293}