escriba-core 0.1.84

Foundational types for the escriba editor — Position, Range, Cursor, Selection, Mode, Motion, Operator, Edit, Action. No I/O, no rendering, pure types.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// Cursor motions — primitive movements the keymap compiles user keys to.
///
/// Two families:
///   - **Text motions** — vim-ish char/word/line/doc/page motions.
///   - **Structural motions** — Lisp-aware `(forward-sexp)` / `(backward-sexp)`
///     / `(up-list)` / `(down-list)` equivalents. Enabled on buffers whose
///     major mode opts in via `(defmajor-mode … :structural-lisp #t)`.
///     Matches paredit's model — equal-or-superior to emacs on Lisp UX.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub enum Motion {
    // ── Text motions (vim-ish base) ────────────────────────────────
    Left,
    Right,
    Up,
    Down,
    WordStartNext,
    WordEndNext,
    WordStartPrev,
    /// `ge` — to the END of the previous word. vim's only backward-inclusive
    /// motion, and the reason `is_inclusive` cannot simply mean "widen right".
    WordEndPrev,
    // ── WORD motions (`W`/`E`/`B`/`gE`) ────────────────────────────
    //
    // vim's second word width: whitespace-delimited, so `foo.bar` is ONE
    // WORD and three words. A separate arm rather than a `Width` field
    // because every call site that resolves a motion has to decide, and a
    // field is a decision a `match` arm cannot forget to make.
    BigWordStartNext,
    BigWordEndNext,
    BigWordStartPrev,
    BigWordEndPrev,
    LineStart,
    LineFirstNonBlank,
    /// `g_` — the LAST non-blank on the line. Inclusive, unlike `$`.
    LineLastNonBlank,
    LineEnd,
    /// `|` — to a 1-based screen column on the current line.
    Column(u32),
    /// `+` / `<CR>` — first non-blank of the next line.
    LineDownFirstNonBlank,
    /// `-` — first non-blank of the previous line.
    LineUpFirstNonBlank,
    /// `_` — count-1 lines downward, on the first non-blank. LINEWISE, which
    /// is the whole reason it is not an alias of [`Self::LineFirstNonBlank`]:
    /// `^` and `_` land the cursor on the same character, and `d^` deletes
    /// back to the indent while `d_` deletes the whole line. Aliasing them —
    /// which escriba did until 2026-08-14 — makes `d_` a no-op at column 0,
    /// because the exclusive range `[cursor, first-non-blank)` is empty there.
    LinewiseDown,
    DocStart,
    DocEnd,
    // ── character search (`f` / `F` / `t` / `T`, and `;` / `,`) ─────
    /// `f{c}` (`backward=false, till=false`), `t{c}` (`till=true`),
    /// `F{c}` / `T{c}` (`backward=true`). The character is carried IN the
    /// motion so `df(` is one composed `ApplyOperator` like every other
    /// operated motion — a separate "pending char" the operator had to read
    /// would be a second composition mechanism beside the FSM.
    FindChar {
        ch: char,
        backward: bool,
        till: bool,
    },
    /// `;` (`reverse=false`) / `,` (`reverse=true`) — repeat the last
    /// [`Motion::FindChar`]. Resolved against runtime state, so like
    /// [`Motion::SearchNext`] the enum stays a pure description.
    RepeatFind {
        reverse: bool,
    },
    /// `%` — to the match of the bracket under (or next on) the cursor.
    MatchPair,
    // ── marks (`m` sets, `` ` `` and `'` jump) ─────────────────────
    /// `` `{a-z} `` — to a mark's exact line AND column.
    MarkExact(char),
    /// `'{a-z}` — to the first non-blank of a mark's LINE. vim's two spellings
    /// are two motions, not one motion and a modifier: `` `a `` is exclusive
    /// and `'a` is linewise, so `d'a` and ``d`a`` delete different things.
    MarkLine(char),
    // ── paragraph / sentence ───────────────────────────────────────
    /// `}` — to the next blank line (paragraph boundary).
    ParagraphNext,
    /// `{` — to the previous blank line.
    ParagraphPrev,
    /// `)` — to the start of the next sentence.
    SentenceNext,
    /// `(` — to the start of the previous sentence.
    SentencePrev,
    // ── viewport-relative (`H` / `M` / `L`) ────────────────────────
    ScreenTop,
    ScreenMiddle,
    ScreenBottom,
    PageUp,
    PageDown,
    HalfPageUp,
    HalfPageDown,
    GotoLine(u32),

    // ── Structural Lisp motions (paredit-grade) ────────────────────
    /// Move to the start of the next sibling s-expression.
    ForwardSexp,
    /// Move to the start of the previous sibling s-expression.
    BackwardSexp,
    /// Move up one parenthesis level — to the opening `(` of the enclosing list.
    UpList,
    /// Move down into the current list — past the opening `(`.
    DownList,
    /// Move to the start of the enclosing top-level defun / top form.
    BeginningOfDefun,
    /// Move to the end of the enclosing top-level defun / top form.
    EndOfDefun,
    /// Move to the start of the current s-expression (current atom / list open).
    BeginningOfSexp,
    /// Move to the end of the current s-expression (matching close).
    EndOfSexp,

    // ── search motions ────────────────────────────────────────────────
    /// To the next search match — vim's `n` used as a MOTION, which is what
    /// makes `d/foo<CR>`, `dn` and `y*` work. Search being a motion rather
    /// than a bare cursor jump is the difference between a search box and vim
    /// search; resolving it needs the committed `SearchState`, so the executor
    /// supplies it — the enum stays a pure description, like every other arm.
    SearchNext,
    /// To the previous search match (vim's `N` as a motion).
    SearchPrev,
}

impl Motion {
    /// Does this motion name a character to ACT ON, rather than a boundary to
    /// stop before?
    ///
    /// vim's exclusive/inclusive split, and it is not cosmetic: `dw` deletes
    /// up to the next word and `de` deletes *through* the current one. An
    /// operator range is `[cursor, target)`, so an inclusive motion's target
    /// has to be widened by one character or the operator leaves the last
    /// character behind — off by exactly one, on the key most likely to be
    /// used to delete a word without its trailing space.
    ///
    /// `RepeatFind` answers `false` here and that is not a classification:
    /// whether `;` is inclusive depends on the direction of the find it
    /// repeats, which is runtime state. The executor resolves it to the
    /// concrete [`Motion::FindChar`] and asks THAT — so there is still exactly
    /// one rule, applied to a known motion.
    ///
    /// Exhaustive `match` since 2026-08-14, for the reason
    /// [`Self::is_linewise`] gives at length: as a `matches!` this answered
    /// `false` for every variant added after it was written, and it had
    /// already been wrong that way. `ge` / `gE` were **backward-inclusive**
    /// (the enum's own note on [`Self::WordEndPrev`] said so) and unlisted, so
    /// `dge` dropped the character under the cursor — `"foo bar baz"` at the
    /// `b` of `baz` gave `"foo babaz"` where vim gives `"foo baaz"`.
    #[must_use]
    pub const fn is_inclusive(self) -> bool {
        match self {
            // Forward-inclusive: the target character is ACTED ON.
            Self::WordEndNext
            | Self::BigWordEndNext
            | Self::LineLastNonBlank
            | Self::MatchPair
            // `f`/`t` only. `F`/`T` are EXCLUSIVE in vim, which is why the
            // pattern binds `backward` rather than using `..` for both.
            | Self::FindChar { backward: false, .. }
            // Backward-inclusive: `ge` / `gE`. vim's rule is "the last
            // character towards the END of the buffer is included", and for a
            // backward motion that end is the CURSOR, not the target — so the
            // widening flips direction. Handled at the operator, which is the
            // only place that knows which way the motion ran.
            | Self::WordEndPrev
            | Self::BigWordEndPrev => true,

            Self::Left
            | Self::Right
            | Self::Up
            | Self::Down
            | Self::WordStartNext
            | Self::WordStartPrev
            | Self::BigWordStartNext
            | Self::BigWordStartPrev
            | Self::LineStart
            | Self::LineFirstNonBlank
            // `$` is exclusive, `g_` inclusive — the whole reason they are two
            // motions and not one plus an offset.
            | Self::LineEnd
            | Self::Column(_)
            | Self::LineDownFirstNonBlank
            | Self::LineUpFirstNonBlank
            | Self::LinewiseDown
            | Self::DocStart
            | Self::DocEnd
            | Self::GotoLine(_)
            | Self::FindChar { backward: true, .. }
            | Self::RepeatFind { .. }
            | Self::MarkExact(_)
            | Self::MarkLine(_)
            | Self::ParagraphNext
            | Self::ParagraphPrev
            | Self::SentenceNext
            | Self::SentencePrev
            | Self::ScreenTop
            | Self::ScreenMiddle
            | Self::ScreenBottom
            | Self::PageUp
            | Self::PageDown
            | Self::HalfPageUp
            | Self::HalfPageDown
            | Self::ForwardSexp
            | Self::BackwardSexp
            | Self::UpList
            | Self::DownList
            | Self::BeginningOfDefun
            | Self::EndOfDefun
            | Self::BeginningOfSexp
            | Self::EndOfSexp
            | Self::SearchNext
            | Self::SearchPrev => false,
        }
    }

    /// Does an operator over this motion act on WHOLE LINES?
    ///
    /// vim has three motion kinds, not two — exclusive, inclusive, and
    /// **linewise** — and escriba modelled only the first two until
    /// 2026-08-14. The consequence was a whole silently-wrong class rather
    /// than one bad key: `dj` deleted one line instead of two, `dgg` stopped a
    /// line short, and every one of them left a **charwise** register, so
    /// `yjp` spliced two lines into the middle of a third instead of opening
    /// lines below. The text was plausible and the register kind was invisible
    /// until a later put, which is why nothing caught it.
    ///
    /// Written as an exhaustive `match` rather than [`matches!`] **on purpose**
    /// — and that is the load-bearing difference from [`Self::is_inclusive`],
    /// which is a `matches!` and therefore answers `false` for any variant
    /// added after it was written. That silent default is exactly how this
    /// class was born: `Down`, `DocEnd`, `ScreenTop` and the rest arrived as
    /// cursor motions, and nobody was ever asked whether they were linewise.
    /// Here a new [`Motion`] fails to compile until it is classified, so the
    /// question cannot be skipped a second time.
    #[must_use]
    pub const fn is_linewise(self) -> bool {
        match self {
            // `j` `k` — the pair the class is most often noticed through.
            Self::Up
            | Self::Down
            // `gg` `G` `{n}G`.
            | Self::DocStart
            | Self::DocEnd
            | Self::GotoLine(_)
            // `H` `M` `L`.
            | Self::ScreenTop
            | Self::ScreenMiddle
            | Self::ScreenBottom
            // `+` `<CR>` `-` `_`.
            | Self::LineDownFirstNonBlank
            | Self::LineUpFirstNonBlank
            | Self::LinewiseDown
            // `'a`. Its sibling `` `a `` is exclusive — two spellings, two
            // motions, which is why they are two variants.
            | Self::MarkLine(_)
            // `<C-f>` `<C-b>` `<C-d>` `<C-u>`. vim does not accept these in
            // operator-pending at all, so there is no vim answer to copy —
            // but escriba DOES bind them as motions, so `d<C-d>` resolves to
            // something either way. Whole lines is the only defensible
            // reading of an operated half-page; charwise ends mid-line at
            // whatever column the cursor happened to hold.
            | Self::PageUp
            | Self::PageDown
            | Self::HalfPageUp
            | Self::HalfPageDown => true,

            // Charwise — exclusive or inclusive, decided by `is_inclusive`.
            Self::Left
            | Self::Right
            | Self::WordStartNext
            | Self::WordEndNext
            | Self::WordStartPrev
            | Self::WordEndPrev
            | Self::BigWordStartNext
            | Self::BigWordEndNext
            | Self::BigWordStartPrev
            | Self::BigWordEndPrev
            | Self::LineStart
            // `^` — the exclusive sibling of `_` above.
            | Self::LineFirstNonBlank
            | Self::LineLastNonBlank
            | Self::LineEnd
            | Self::Column(_)
            | Self::FindChar { .. }
            | Self::RepeatFind { .. }
            | Self::MatchPair
            | Self::MarkExact(_)
            // `{` `}` `(` `)` are EXCLUSIVE in vim, not linewise — a
            // reasonable-sounding guess that would make `d}` eat the blank
            // line terminating the paragraph.
            | Self::ParagraphNext
            | Self::ParagraphPrev
            | Self::SentenceNext
            | Self::SentencePrev
            | Self::ForwardSexp
            | Self::BackwardSexp
            | Self::UpList
            | Self::DownList
            | Self::BeginningOfDefun
            | Self::EndOfDefun
            | Self::BeginningOfSexp
            | Self::EndOfSexp
            // `d/foo<CR>` and `dn` are exclusive charwise in vim.
            | Self::SearchNext
            | Self::SearchPrev => false,
        }
    }

    #[must_use]
    pub const fn is_structural(self) -> bool {
        matches!(
            self,
            Self::ForwardSexp
                | Self::BackwardSexp
                | Self::UpList
                | Self::DownList
                | Self::BeginningOfDefun
                | Self::EndOfDefun
                | Self::BeginningOfSexp
                | Self::EndOfSexp,
        )
    }
}

/// Operators — vim-style verbs. Combined with a motion they produce an edit.
///
/// Structural operators (paredit-grade) compose with structural motions:
///   - `(slurp-forward)` — pull the next sibling into the current list
///   - `(barf-forward)` — push the last child out of the current list
///   - `(splice)` — unwrap the current list (remove parens, keep children)
///   - `(wrap)` — wrap the target in a new list
///   - `(raise)` — replace the enclosing list with the current sexp
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub enum Operator {
    Delete,
    Yank,
    Change,
    Indent,
    Dedent,
    Filter,
    Format,
    // ── Structural (Lisp-aware) operators ──────────────────────────
    SlurpForward,
    SlurpBackward,
    BarfForward,
    BarfBackward,
    Splice,
    Wrap,
    Raise,
}

impl Operator {
    #[must_use]
    pub const fn leaves_register(self) -> bool {
        matches!(self, Self::Delete | Self::Yank | Self::Change)
    }

    #[must_use]
    pub const fn is_structural(self) -> bool {
        matches!(
            self,
            Self::SlurpForward
                | Self::SlurpBackward
                | Self::BarfForward
                | Self::BarfBackward
                | Self::Splice
                | Self::Wrap
                | Self::Raise,
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn register_emitting_ops() {
        assert!(Operator::Delete.leaves_register());
        assert!(Operator::Yank.leaves_register());
        assert!(Operator::Change.leaves_register());
        assert!(!Operator::Format.leaves_register());
    }
}

/// A text EXTENT an operator can act over, as opposed to a point it moves to.
///
/// vim's `gn` is the motivating case and shows why the distinction matters:
/// `dgn` deletes the next match *wherever it is*, including when the cursor is
/// nowhere near it. Modelled as a motion it would resolve to the match's start
/// and the operator would act over `[cursor, match.start)` — deleting the text
/// BEFORE the match instead of the match. Same keys, opposite effect.
///
/// Closed, so an unhandled object cannot reach the executor.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub enum TextObject {
    /// `gn` — the next search match at or after the cursor.
    NextMatch,
    /// `gN` — the previous search match at or before the cursor.
    PrevMatch,
    /// `dd` / `cc` / `yy` — the current line, LINEWISE.
    ///
    /// A doubled operator in vim acts on whole lines, trailing newline
    /// included, which is why this is an object rather than a motion: there
    /// is no cursor-to-target range that expresses "this line and its
    /// terminator" without special-casing the last line.
    Line,
    /// `iw` / `aw` — the word under the cursor.
    ///
    /// `around: true` takes the trailing run of whitespace as well, which is
    /// the whole difference vim draws between `diw` and `daw`.
    Word { around: bool },
    /// `i(` `a{` `i"` … — the region between a matched pair.
    ///
    /// One variant covers brackets and quotes because the only thing that
    /// differs is whether the delimiters nest; carrying `open`/`close`
    /// separately lets a quote say `open == close` instead of needing its
    /// own arm.
    Delimited {
        open: char,
        close: char,
        around: bool,
    },
}

impl TextObject {
    /// How an operator over this object leaves the register — and therefore
    /// how a later `p` replays it.
    ///
    /// **Total over `TextObject`, no wildcard arm.** The mapping lives here,
    /// beside the variants, rather than at the one call site that needs it
    /// today: a new linewise object (vim's `ip`/`ap` paragraph objects are the
    /// obvious next ones) must decide, and a wildcard would silently answer
    /// `Charwise` for them — the direction that pastes a paragraph into the
    /// middle of whatever line the cursor happens to be on.
    #[must_use]
    pub const fn register_kind(self) -> crate::register::RegisterKind {
        use crate::register::RegisterKind as K;
        match self {
            Self::Line => K::Linewise,
            Self::NextMatch | Self::PrevMatch | Self::Word { .. } | Self::Delimited { .. } => {
                K::Charwise
            }
        }
    }
}