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 LineStart,
23 LineFirstNonBlank,
24 LineEnd,
25 DocStart,
26 DocEnd,
27 PageUp,
28 PageDown,
29 HalfPageUp,
30 HalfPageDown,
31 GotoLine(u32),
32
33 // ── Structural Lisp motions (paredit-grade) ────────────────────
34 /// Move to the start of the next sibling s-expression.
35 ForwardSexp,
36 /// Move to the start of the previous sibling s-expression.
37 BackwardSexp,
38 /// Move up one parenthesis level — to the opening `(` of the enclosing list.
39 UpList,
40 /// Move down into the current list — past the opening `(`.
41 DownList,
42 /// Move to the start of the enclosing top-level defun / top form.
43 BeginningOfDefun,
44 /// Move to the end of the enclosing top-level defun / top form.
45 EndOfDefun,
46 /// Move to the start of the current s-expression (current atom / list open).
47 BeginningOfSexp,
48 /// Move to the end of the current s-expression (matching close).
49 EndOfSexp,
50
51 // ── search motions ────────────────────────────────────────────────
52 /// To the next search match — vim's `n` used as a MOTION, which is what
53 /// makes `d/foo<CR>`, `dn` and `y*` work. Search being a motion rather
54 /// than a bare cursor jump is the difference between a search box and vim
55 /// search; resolving it needs the committed `SearchState`, so the executor
56 /// supplies it — the enum stays a pure description, like every other arm.
57 SearchNext,
58 /// To the previous search match (vim's `N` as a motion).
59 SearchPrev,
60}
61
62impl Motion {
63 /// Does this motion name a character to ACT ON, rather than a boundary to
64 /// stop before?
65 ///
66 /// vim's exclusive/inclusive split, and it is not cosmetic: `dw` deletes
67 /// up to the next word and `de` deletes *through* the current one. An
68 /// operator range is `[cursor, target)`, so an inclusive motion's target
69 /// has to be widened by one character or the operator leaves the last
70 /// character behind — off by exactly one, on the key most likely to be
71 /// used to delete a word without its trailing space.
72 ///
73 /// `WordEndNext` is the only inclusive motion escriba has today. `f`/`t`/
74 /// `%` are the others in vim and are not bound yet; each lands here when
75 /// it does, which is the point of asking the MOTION rather than
76 /// special-casing `e` at the operator.
77 #[must_use]
78 pub const fn is_inclusive(self) -> bool {
79 matches!(self, Self::WordEndNext)
80 }
81
82 #[must_use]
83 pub const fn is_structural(self) -> bool {
84 matches!(
85 self,
86 Self::ForwardSexp
87 | Self::BackwardSexp
88 | Self::UpList
89 | Self::DownList
90 | Self::BeginningOfDefun
91 | Self::EndOfDefun
92 | Self::BeginningOfSexp
93 | Self::EndOfSexp,
94 )
95 }
96}
97
98/// Operators — vim-style verbs. Combined with a motion they produce an edit.
99///
100/// Structural operators (paredit-grade) compose with structural motions:
101/// - `(slurp-forward)` — pull the next sibling into the current list
102/// - `(barf-forward)` — push the last child out of the current list
103/// - `(splice)` — unwrap the current list (remove parens, keep children)
104/// - `(wrap)` — wrap the target in a new list
105/// - `(raise)` — replace the enclosing list with the current sexp
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
107pub enum Operator {
108 Delete,
109 Yank,
110 Change,
111 Indent,
112 Dedent,
113 Filter,
114 Format,
115 // ── Structural (Lisp-aware) operators ──────────────────────────
116 SlurpForward,
117 SlurpBackward,
118 BarfForward,
119 BarfBackward,
120 Splice,
121 Wrap,
122 Raise,
123}
124
125impl Operator {
126 #[must_use]
127 pub const fn leaves_register(self) -> bool {
128 matches!(self, Self::Delete | Self::Yank | Self::Change)
129 }
130
131 #[must_use]
132 pub const fn is_structural(self) -> bool {
133 matches!(
134 self,
135 Self::SlurpForward
136 | Self::SlurpBackward
137 | Self::BarfForward
138 | Self::BarfBackward
139 | Self::Splice
140 | Self::Wrap
141 | Self::Raise,
142 )
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn register_emitting_ops() {
152 assert!(Operator::Delete.leaves_register());
153 assert!(Operator::Yank.leaves_register());
154 assert!(Operator::Change.leaves_register());
155 assert!(!Operator::Format.leaves_register());
156 }
157}
158
159/// A text EXTENT an operator can act over, as opposed to a point it moves to.
160///
161/// vim's `gn` is the motivating case and shows why the distinction matters:
162/// `dgn` deletes the next match *wherever it is*, including when the cursor is
163/// nowhere near it. Modelled as a motion it would resolve to the match's start
164/// and the operator would act over `[cursor, match.start)` — deleting the text
165/// BEFORE the match instead of the match. Same keys, opposite effect.
166///
167/// Closed, so an unhandled object cannot reach the executor.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
169pub enum TextObject {
170 /// `gn` — the next search match at or after the cursor.
171 NextMatch,
172 /// `gN` — the previous search match at or before the cursor.
173 PrevMatch,
174 /// `dd` / `cc` / `yy` — the current line, LINEWISE.
175 ///
176 /// A doubled operator in vim acts on whole lines, trailing newline
177 /// included, which is why this is an object rather than a motion: there
178 /// is no cursor-to-target range that expresses "this line and its
179 /// terminator" without special-casing the last line.
180 Line,
181 /// `iw` / `aw` — the word under the cursor.
182 ///
183 /// `around: true` takes the trailing run of whitespace as well, which is
184 /// the whole difference vim draws between `diw` and `daw`.
185 Word { around: bool },
186 /// `i(` `a{` `i"` … — the region between a matched pair.
187 ///
188 /// One variant covers brackets and quotes because the only thing that
189 /// differs is whether the delimiters nest; carrying `open`/`close`
190 /// separately lets a quote say `open == close` instead of needing its
191 /// own arm.
192 Delimited {
193 open: char,
194 close: char,
195 around: bool,
196 },
197}