Skip to main content

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    /// `RepeatFind` answers `false` here and that is not a classification:
141    /// whether `;` is inclusive depends on the direction of the find it
142    /// repeats, which is runtime state. The executor resolves it to the
143    /// concrete [`Motion::FindChar`] and asks THAT — so there is still exactly
144    /// one rule, applied to a known motion.
145    ///
146    /// Exhaustive `match` since 2026-08-14, for the reason
147    /// [`Self::is_linewise`] gives at length: as a `matches!` this answered
148    /// `false` for every variant added after it was written, and it had
149    /// already been wrong that way. `ge` / `gE` were **backward-inclusive**
150    /// (the enum's own note on [`Self::WordEndPrev`] said so) and unlisted, so
151    /// `dge` dropped the character under the cursor — `"foo bar baz"` at the
152    /// `b` of `baz` gave `"foo babaz"` where vim gives `"foo baaz"`.
153    #[must_use]
154    pub const fn is_inclusive(self) -> bool {
155        match self {
156            // Forward-inclusive: the target character is ACTED ON.
157            Self::WordEndNext
158            | Self::BigWordEndNext
159            | Self::LineLastNonBlank
160            | Self::MatchPair
161            // `f`/`t` only. `F`/`T` are EXCLUSIVE in vim, which is why the
162            // pattern binds `backward` rather than using `..` for both.
163            | Self::FindChar { backward: false, .. }
164            // Backward-inclusive: `ge` / `gE`. vim's rule is "the last
165            // character towards the END of the buffer is included", and for a
166            // backward motion that end is the CURSOR, not the target — so the
167            // widening flips direction. Handled at the operator, which is the
168            // only place that knows which way the motion ran.
169            | Self::WordEndPrev
170            | Self::BigWordEndPrev => true,
171
172            Self::Left
173            | Self::Right
174            | Self::Up
175            | Self::Down
176            | Self::WordStartNext
177            | Self::WordStartPrev
178            | Self::BigWordStartNext
179            | Self::BigWordStartPrev
180            | Self::LineStart
181            | Self::LineFirstNonBlank
182            // `$` is exclusive, `g_` inclusive — the whole reason they are two
183            // motions and not one plus an offset.
184            | Self::LineEnd
185            | Self::Column(_)
186            | Self::LineDownFirstNonBlank
187            | Self::LineUpFirstNonBlank
188            | Self::LinewiseDown
189            | Self::DocStart
190            | Self::DocEnd
191            | Self::GotoLine(_)
192            | Self::FindChar { backward: true, .. }
193            | Self::RepeatFind { .. }
194            | Self::MarkExact(_)
195            | Self::MarkLine(_)
196            | Self::ParagraphNext
197            | Self::ParagraphPrev
198            | Self::SentenceNext
199            | Self::SentencePrev
200            | Self::ScreenTop
201            | Self::ScreenMiddle
202            | Self::ScreenBottom
203            | Self::PageUp
204            | Self::PageDown
205            | Self::HalfPageUp
206            | Self::HalfPageDown
207            | Self::ForwardSexp
208            | Self::BackwardSexp
209            | Self::UpList
210            | Self::DownList
211            | Self::BeginningOfDefun
212            | Self::EndOfDefun
213            | Self::BeginningOfSexp
214            | Self::EndOfSexp
215            | Self::SearchNext
216            | Self::SearchPrev => false,
217        }
218    }
219
220    /// Does an operator over this motion act on WHOLE LINES?
221    ///
222    /// vim has three motion kinds, not two — exclusive, inclusive, and
223    /// **linewise** — and escriba modelled only the first two until
224    /// 2026-08-14. The consequence was a whole silently-wrong class rather
225    /// than one bad key: `dj` deleted one line instead of two, `dgg` stopped a
226    /// line short, and every one of them left a **charwise** register, so
227    /// `yjp` spliced two lines into the middle of a third instead of opening
228    /// lines below. The text was plausible and the register kind was invisible
229    /// until a later put, which is why nothing caught it.
230    ///
231    /// Written as an exhaustive `match` rather than [`matches!`] **on purpose**
232    /// — and that is the load-bearing difference from [`Self::is_inclusive`],
233    /// which is a `matches!` and therefore answers `false` for any variant
234    /// added after it was written. That silent default is exactly how this
235    /// class was born: `Down`, `DocEnd`, `ScreenTop` and the rest arrived as
236    /// cursor motions, and nobody was ever asked whether they were linewise.
237    /// Here a new [`Motion`] fails to compile until it is classified, so the
238    /// question cannot be skipped a second time.
239    #[must_use]
240    pub const fn is_linewise(self) -> bool {
241        match self {
242            // `j` `k` — the pair the class is most often noticed through.
243            Self::Up
244            | Self::Down
245            // `gg` `G` `{n}G`.
246            | Self::DocStart
247            | Self::DocEnd
248            | Self::GotoLine(_)
249            // `H` `M` `L`.
250            | Self::ScreenTop
251            | Self::ScreenMiddle
252            | Self::ScreenBottom
253            // `+` `<CR>` `-` `_`.
254            | Self::LineDownFirstNonBlank
255            | Self::LineUpFirstNonBlank
256            | Self::LinewiseDown
257            // `'a`. Its sibling `` `a `` is exclusive — two spellings, two
258            // motions, which is why they are two variants.
259            | Self::MarkLine(_)
260            // `<C-f>` `<C-b>` `<C-d>` `<C-u>`. vim does not accept these in
261            // operator-pending at all, so there is no vim answer to copy —
262            // but escriba DOES bind them as motions, so `d<C-d>` resolves to
263            // something either way. Whole lines is the only defensible
264            // reading of an operated half-page; charwise ends mid-line at
265            // whatever column the cursor happened to hold.
266            | Self::PageUp
267            | Self::PageDown
268            | Self::HalfPageUp
269            | Self::HalfPageDown => true,
270
271            // Charwise — exclusive or inclusive, decided by `is_inclusive`.
272            Self::Left
273            | Self::Right
274            | Self::WordStartNext
275            | Self::WordEndNext
276            | Self::WordStartPrev
277            | Self::WordEndPrev
278            | Self::BigWordStartNext
279            | Self::BigWordEndNext
280            | Self::BigWordStartPrev
281            | Self::BigWordEndPrev
282            | Self::LineStart
283            // `^` — the exclusive sibling of `_` above.
284            | Self::LineFirstNonBlank
285            | Self::LineLastNonBlank
286            | Self::LineEnd
287            | Self::Column(_)
288            | Self::FindChar { .. }
289            | Self::RepeatFind { .. }
290            | Self::MatchPair
291            | Self::MarkExact(_)
292            // `{` `}` `(` `)` are EXCLUSIVE in vim, not linewise — a
293            // reasonable-sounding guess that would make `d}` eat the blank
294            // line terminating the paragraph.
295            | Self::ParagraphNext
296            | Self::ParagraphPrev
297            | Self::SentenceNext
298            | Self::SentencePrev
299            | Self::ForwardSexp
300            | Self::BackwardSexp
301            | Self::UpList
302            | Self::DownList
303            | Self::BeginningOfDefun
304            | Self::EndOfDefun
305            | Self::BeginningOfSexp
306            | Self::EndOfSexp
307            // `d/foo<CR>` and `dn` are exclusive charwise in vim.
308            | Self::SearchNext
309            | Self::SearchPrev => false,
310        }
311    }
312
313    #[must_use]
314    pub const fn is_structural(self) -> bool {
315        matches!(
316            self,
317            Self::ForwardSexp
318                | Self::BackwardSexp
319                | Self::UpList
320                | Self::DownList
321                | Self::BeginningOfDefun
322                | Self::EndOfDefun
323                | Self::BeginningOfSexp
324                | Self::EndOfSexp,
325        )
326    }
327}
328
329/// Operators — vim-style verbs. Combined with a motion they produce an edit.
330///
331/// Structural operators (paredit-grade) compose with structural motions:
332///   - `(slurp-forward)` — pull the next sibling into the current list
333///   - `(barf-forward)` — push the last child out of the current list
334///   - `(splice)` — unwrap the current list (remove parens, keep children)
335///   - `(wrap)` — wrap the target in a new list
336///   - `(raise)` — replace the enclosing list with the current sexp
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
338pub enum Operator {
339    Delete,
340    Yank,
341    Change,
342    Indent,
343    Dedent,
344    Filter,
345    Format,
346    // ── Structural (Lisp-aware) operators ──────────────────────────
347    SlurpForward,
348    SlurpBackward,
349    BarfForward,
350    BarfBackward,
351    Splice,
352    Wrap,
353    Raise,
354}
355
356impl Operator {
357    #[must_use]
358    pub const fn leaves_register(self) -> bool {
359        matches!(self, Self::Delete | Self::Yank | Self::Change)
360    }
361
362    #[must_use]
363    pub const fn is_structural(self) -> bool {
364        matches!(
365            self,
366            Self::SlurpForward
367                | Self::SlurpBackward
368                | Self::BarfForward
369                | Self::BarfBackward
370                | Self::Splice
371                | Self::Wrap
372                | Self::Raise,
373        )
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn register_emitting_ops() {
383        assert!(Operator::Delete.leaves_register());
384        assert!(Operator::Yank.leaves_register());
385        assert!(Operator::Change.leaves_register());
386        assert!(!Operator::Format.leaves_register());
387    }
388}
389
390/// A text EXTENT an operator can act over, as opposed to a point it moves to.
391///
392/// vim's `gn` is the motivating case and shows why the distinction matters:
393/// `dgn` deletes the next match *wherever it is*, including when the cursor is
394/// nowhere near it. Modelled as a motion it would resolve to the match's start
395/// and the operator would act over `[cursor, match.start)` — deleting the text
396/// BEFORE the match instead of the match. Same keys, opposite effect.
397///
398/// Closed, so an unhandled object cannot reach the executor.
399#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
400pub enum TextObject {
401    /// `gn` — the next search match at or after the cursor.
402    NextMatch,
403    /// `gN` — the previous search match at or before the cursor.
404    PrevMatch,
405    /// `dd` / `cc` / `yy` — the current line, LINEWISE.
406    ///
407    /// A doubled operator in vim acts on whole lines, trailing newline
408    /// included, which is why this is an object rather than a motion: there
409    /// is no cursor-to-target range that expresses "this line and its
410    /// terminator" without special-casing the last line.
411    Line,
412    /// `iw` / `aw` — the word under the cursor.
413    ///
414    /// `around: true` takes the trailing run of whitespace as well, which is
415    /// the whole difference vim draws between `diw` and `daw`.
416    Word { around: bool },
417    /// `i(` `a{` `i"` … — the region between a matched pair.
418    ///
419    /// One variant covers brackets and quotes because the only thing that
420    /// differs is whether the delimiters nest; carrying `open`/`close`
421    /// separately lets a quote say `open == close` instead of needing its
422    /// own arm.
423    Delimited {
424        open: char,
425        close: char,
426        around: bool,
427    },
428}
429
430impl TextObject {
431    /// How an operator over this object leaves the register — and therefore
432    /// how a later `p` replays it.
433    ///
434    /// **Total over `TextObject`, no wildcard arm.** The mapping lives here,
435    /// beside the variants, rather than at the one call site that needs it
436    /// today: a new linewise object (vim's `ip`/`ap` paragraph objects are the
437    /// obvious next ones) must decide, and a wildcard would silently answer
438    /// `Charwise` for them — the direction that pastes a paragraph into the
439    /// middle of whatever line the cursor happens to be on.
440    #[must_use]
441    pub const fn register_kind(self) -> crate::register::RegisterKind {
442        use crate::register::RegisterKind as K;
443        match self {
444            Self::Line => K::Linewise,
445            Self::NextMatch | Self::PrevMatch | Self::Word { .. } | Self::Delimited { .. } => {
446                K::Charwise
447            }
448        }
449    }
450}