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 /// `_` — count-1 lines downward, on the first non-blank. LINEWISE, which
47 /// is the whole reason it is not an alias of [`Self::LineFirstNonBlank`]:
48 /// `^` and `_` land the cursor on the same character, and `d^` deletes
49 /// back to the indent while `d_` deletes the whole line. Aliasing them —
50 /// which escriba did until 2026-08-14 — makes `d_` a no-op at column 0,
51 /// because the exclusive range `[cursor, first-non-blank)` is empty there.
52 LinewiseDown,
53 DocStart,
54 DocEnd,
55 // ── character search (`f` / `F` / `t` / `T`, and `;` / `,`) ─────
56 /// `f{c}` (`backward=false, till=false`), `t{c}` (`till=true`),
57 /// `F{c}` / `T{c}` (`backward=true`). The character is carried IN the
58 /// motion so `df(` is one composed `ApplyOperator` like every other
59 /// operated motion — a separate "pending char" the operator had to read
60 /// would be a second composition mechanism beside the FSM.
61 FindChar {
62 ch: char,
63 backward: bool,
64 till: bool,
65 },
66 /// `;` (`reverse=false`) / `,` (`reverse=true`) — repeat the last
67 /// [`Motion::FindChar`]. Resolved against runtime state, so like
68 /// [`Motion::SearchNext`] the enum stays a pure description.
69 RepeatFind {
70 reverse: bool,
71 },
72 /// `%` — to the match of the bracket under (or next on) the cursor.
73 MatchPair,
74 // ── marks (`m` sets, `` ` `` and `'` jump) ─────────────────────
75 /// `` `{a-z} `` — to a mark's exact line AND column.
76 MarkExact(char),
77 /// `'{a-z}` — to the first non-blank of a mark's LINE. vim's two spellings
78 /// are two motions, not one motion and a modifier: `` `a `` is exclusive
79 /// and `'a` is linewise, so `d'a` and ``d`a`` delete different things.
80 MarkLine(char),
81 // ── paragraph / sentence ───────────────────────────────────────
82 /// `}` — to the next blank line (paragraph boundary).
83 ParagraphNext,
84 /// `{` — to the previous blank line.
85 ParagraphPrev,
86 /// `)` — to the start of the next sentence.
87 SentenceNext,
88 /// `(` — to the start of the previous sentence.
89 SentencePrev,
90 // ── viewport-relative (`H` / `M` / `L`) ────────────────────────
91 ScreenTop,
92 ScreenMiddle,
93 ScreenBottom,
94 PageUp,
95 PageDown,
96 HalfPageUp,
97 HalfPageDown,
98 GotoLine(u32),
99
100 // ── Structural Lisp motions (paredit-grade) ────────────────────
101 /// Move to the start of the next sibling s-expression.
102 ForwardSexp,
103 /// Move to the start of the previous sibling s-expression.
104 BackwardSexp,
105 /// Move up one parenthesis level — to the opening `(` of the enclosing list.
106 UpList,
107 /// Move down into the current list — past the opening `(`.
108 DownList,
109 /// Move to the start of the enclosing top-level defun / top form.
110 BeginningOfDefun,
111 /// Move to the end of the enclosing top-level defun / top form.
112 EndOfDefun,
113 /// Move to the start of the current s-expression (current atom / list open).
114 BeginningOfSexp,
115 /// Move to the end of the current s-expression (matching close).
116 EndOfSexp,
117
118 // ── search motions ────────────────────────────────────────────────
119 /// To the next search match — vim's `n` used as a MOTION, which is what
120 /// makes `d/foo<CR>`, `dn` and `y*` work. Search being a motion rather
121 /// than a bare cursor jump is the difference between a search box and vim
122 /// search; resolving it needs the committed `SearchState`, so the executor
123 /// supplies it — the enum stays a pure description, like every other arm.
124 SearchNext,
125 /// To the previous search match (vim's `N` as a motion).
126 SearchPrev,
127}
128
129impl Motion {
130 /// Does this motion name a character to ACT ON, rather than a boundary to
131 /// stop before?
132 ///
133 /// vim's exclusive/inclusive split, and it is not cosmetic: `dw` deletes
134 /// up to the next word and `de` deletes *through* the current one. An
135 /// operator range is `[cursor, target)`, so an inclusive motion's target
136 /// has to be widened by one character or the operator leaves the last
137 /// character behind — off by exactly one, on the key most likely to be
138 /// used to delete a word without its trailing space.
139 ///
140 /// `WordEndNext` is the only inclusive motion escriba has today. `f`/`t`/
141 /// `%` are the others in vim and are not bound yet; each lands here when
142 /// it does, which is the point of asking the MOTION rather than
143 /// special-casing `e` at the operator.
144 #[must_use]
145 /// `RepeatFind` is deliberately absent: whether `;` is inclusive depends
146 /// on the direction of the find it repeats, which is runtime state. The
147 /// executor resolves it to the concrete [`Motion::FindChar`] and asks
148 /// THAT — so there is still exactly one rule, applied to a known motion.
149 pub const fn is_inclusive(self) -> bool {
150 matches!(
151 self,
152 Self::WordEndNext
153 | Self::BigWordEndNext
154 | Self::LineLastNonBlank
155 | Self::MatchPair
156 | Self::FindChar {
157 backward: false,
158 ..
159 }
160 )
161 }
162
163 /// Does an operator over this motion act on WHOLE LINES?
164 ///
165 /// vim has three motion kinds, not two — exclusive, inclusive, and
166 /// **linewise** — and escriba modelled only the first two until
167 /// 2026-08-14. The consequence was a whole silently-wrong class rather
168 /// than one bad key: `dj` deleted one line instead of two, `dgg` stopped a
169 /// line short, and every one of them left a **charwise** register, so
170 /// `yjp` spliced two lines into the middle of a third instead of opening
171 /// lines below. The text was plausible and the register kind was invisible
172 /// until a later put, which is why nothing caught it.
173 ///
174 /// Written as an exhaustive `match` rather than [`matches!`] **on purpose**
175 /// — and that is the load-bearing difference from [`Self::is_inclusive`],
176 /// which is a `matches!` and therefore answers `false` for any variant
177 /// added after it was written. That silent default is exactly how this
178 /// class was born: `Down`, `DocEnd`, `ScreenTop` and the rest arrived as
179 /// cursor motions, and nobody was ever asked whether they were linewise.
180 /// Here a new [`Motion`] fails to compile until it is classified, so the
181 /// question cannot be skipped a second time.
182 #[must_use]
183 pub const fn is_linewise(self) -> bool {
184 match self {
185 // `j` `k` — the pair the class is most often noticed through.
186 Self::Up
187 | Self::Down
188 // `gg` `G` `{n}G`.
189 | Self::DocStart
190 | Self::DocEnd
191 | Self::GotoLine(_)
192 // `H` `M` `L`.
193 | Self::ScreenTop
194 | Self::ScreenMiddle
195 | Self::ScreenBottom
196 // `+` `<CR>` `-` `_`.
197 | Self::LineDownFirstNonBlank
198 | Self::LineUpFirstNonBlank
199 | Self::LinewiseDown
200 // `'a`. Its sibling `` `a `` is exclusive — two spellings, two
201 // motions, which is why they are two variants.
202 | Self::MarkLine(_)
203 // `<C-f>` `<C-b>` `<C-d>` `<C-u>`. vim does not accept these in
204 // operator-pending at all, so there is no vim answer to copy —
205 // but escriba DOES bind them as motions, so `d<C-d>` resolves to
206 // something either way. Whole lines is the only defensible
207 // reading of an operated half-page; charwise ends mid-line at
208 // whatever column the cursor happened to hold.
209 | Self::PageUp
210 | Self::PageDown
211 | Self::HalfPageUp
212 | Self::HalfPageDown => true,
213
214 // Charwise — exclusive or inclusive, decided by `is_inclusive`.
215 Self::Left
216 | Self::Right
217 | Self::WordStartNext
218 | Self::WordEndNext
219 | Self::WordStartPrev
220 | Self::WordEndPrev
221 | Self::BigWordStartNext
222 | Self::BigWordEndNext
223 | Self::BigWordStartPrev
224 | Self::BigWordEndPrev
225 | Self::LineStart
226 // `^` — the exclusive sibling of `_` above.
227 | Self::LineFirstNonBlank
228 | Self::LineLastNonBlank
229 | Self::LineEnd
230 | Self::Column(_)
231 | Self::FindChar { .. }
232 | Self::RepeatFind { .. }
233 | Self::MatchPair
234 | Self::MarkExact(_)
235 // `{` `}` `(` `)` are EXCLUSIVE in vim, not linewise — a
236 // reasonable-sounding guess that would make `d}` eat the blank
237 // line terminating the paragraph.
238 | Self::ParagraphNext
239 | Self::ParagraphPrev
240 | Self::SentenceNext
241 | Self::SentencePrev
242 | Self::ForwardSexp
243 | Self::BackwardSexp
244 | Self::UpList
245 | Self::DownList
246 | Self::BeginningOfDefun
247 | Self::EndOfDefun
248 | Self::BeginningOfSexp
249 | Self::EndOfSexp
250 // `d/foo<CR>` and `dn` are exclusive charwise in vim.
251 | Self::SearchNext
252 | Self::SearchPrev => false,
253 }
254 }
255
256 #[must_use]
257 pub const fn is_structural(self) -> bool {
258 matches!(
259 self,
260 Self::ForwardSexp
261 | Self::BackwardSexp
262 | Self::UpList
263 | Self::DownList
264 | Self::BeginningOfDefun
265 | Self::EndOfDefun
266 | Self::BeginningOfSexp
267 | Self::EndOfSexp,
268 )
269 }
270}
271
272/// Operators — vim-style verbs. Combined with a motion they produce an edit.
273///
274/// Structural operators (paredit-grade) compose with structural motions:
275/// - `(slurp-forward)` — pull the next sibling into the current list
276/// - `(barf-forward)` — push the last child out of the current list
277/// - `(splice)` — unwrap the current list (remove parens, keep children)
278/// - `(wrap)` — wrap the target in a new list
279/// - `(raise)` — replace the enclosing list with the current sexp
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
281pub enum Operator {
282 Delete,
283 Yank,
284 Change,
285 Indent,
286 Dedent,
287 Filter,
288 Format,
289 // ── Structural (Lisp-aware) operators ──────────────────────────
290 SlurpForward,
291 SlurpBackward,
292 BarfForward,
293 BarfBackward,
294 Splice,
295 Wrap,
296 Raise,
297}
298
299impl Operator {
300 #[must_use]
301 pub const fn leaves_register(self) -> bool {
302 matches!(self, Self::Delete | Self::Yank | Self::Change)
303 }
304
305 #[must_use]
306 pub const fn is_structural(self) -> bool {
307 matches!(
308 self,
309 Self::SlurpForward
310 | Self::SlurpBackward
311 | Self::BarfForward
312 | Self::BarfBackward
313 | Self::Splice
314 | Self::Wrap
315 | Self::Raise,
316 )
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn register_emitting_ops() {
326 assert!(Operator::Delete.leaves_register());
327 assert!(Operator::Yank.leaves_register());
328 assert!(Operator::Change.leaves_register());
329 assert!(!Operator::Format.leaves_register());
330 }
331}
332
333/// A text EXTENT an operator can act over, as opposed to a point it moves to.
334///
335/// vim's `gn` is the motivating case and shows why the distinction matters:
336/// `dgn` deletes the next match *wherever it is*, including when the cursor is
337/// nowhere near it. Modelled as a motion it would resolve to the match's start
338/// and the operator would act over `[cursor, match.start)` — deleting the text
339/// BEFORE the match instead of the match. Same keys, opposite effect.
340///
341/// Closed, so an unhandled object cannot reach the executor.
342#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
343pub enum TextObject {
344 /// `gn` — the next search match at or after the cursor.
345 NextMatch,
346 /// `gN` — the previous search match at or before the cursor.
347 PrevMatch,
348 /// `dd` / `cc` / `yy` — the current line, LINEWISE.
349 ///
350 /// A doubled operator in vim acts on whole lines, trailing newline
351 /// included, which is why this is an object rather than a motion: there
352 /// is no cursor-to-target range that expresses "this line and its
353 /// terminator" without special-casing the last line.
354 Line,
355 /// `iw` / `aw` — the word under the cursor.
356 ///
357 /// `around: true` takes the trailing run of whitespace as well, which is
358 /// the whole difference vim draws between `diw` and `daw`.
359 Word { around: bool },
360 /// `i(` `a{` `i"` … — the region between a matched pair.
361 ///
362 /// One variant covers brackets and quotes because the only thing that
363 /// differs is whether the delimiters nest; carrying `open`/`close`
364 /// separately lets a quote say `open == close` instead of needing its
365 /// own arm.
366 Delimited {
367 open: char,
368 close: char,
369 around: bool,
370 },
371}
372
373impl TextObject {
374 /// How an operator over this object leaves the register — and therefore
375 /// how a later `p` replays it.
376 ///
377 /// **Total over `TextObject`, no wildcard arm.** The mapping lives here,
378 /// beside the variants, rather than at the one call site that needs it
379 /// today: a new linewise object (vim's `ip`/`ap` paragraph objects are the
380 /// obvious next ones) must decide, and a wildcard would silently answer
381 /// `Charwise` for them — the direction that pastes a paragraph into the
382 /// middle of whatever line the cursor happens to be on.
383 #[must_use]
384 pub const fn register_kind(self) -> crate::register::RegisterKind {
385 use crate::register::RegisterKind as K;
386 match self {
387 Self::Line => K::Linewise,
388 Self::NextMatch | Self::PrevMatch | Self::Word { .. } | Self::Delimited { .. } => {
389 K::Charwise
390 }
391 }
392 }
393}