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