escriba_mode/lib.rs
1//! `escriba-mode` — modal state machine, typed as a sum so illegal mode
2//! field combinations are *unrepresentable*.
3//!
4//! Phase 1: vim-ish Normal/Insert/Visual/VisualLine/Command. The earlier
5//! design was a product type — `{ mode, pending_count, pending_operator,
6//! minibuffer }` — where nonsense like `mode: Insert` carrying a
7//! `pending_operator: Some(Delete)` was *constructible* and only kept sane
8//! by a runtime guard inside `enter()`. Per the org-level ★★
9//! UNREPRESENTABILITY rule, the fix is structural: model the per-mode state
10//! as a SUM so each mode carries ONLY the data that is valid in that mode.
11//!
12//! - `Normal` carries the pending count (`5dd`) + pending operator (the `d`
13//! in `dw`) — these exist NOWHERE else.
14//! - `Insert` / `VisualLine` carry nothing.
15//! - `Visual` carries nothing in phase 1 (a future `anchor: Position` lands
16//! here, in the one variant where a visual anchor is meaningful).
17//! - `Command` carries ONLY the accumulating minibuffer line.
18//!
19//! There is no way to construct a `Command` without dropping the pending
20//! operator, or an `Insert` that still holds a count — the type system
21//! refuses it. The only way to change mode is the typed transition methods
22//! (`enter_*` / `enter` / `escape`), so the per-mode invariant is enforced
23//! by construction at every transition, not re-checked by a guard.
24//!
25//! Typestate destination: a future revision can promote this to a
26//! phantom-typestate `Modal<P>` where illegal *transitions* (not just
27//! illegal field combos) are `E0599` compile errors. That is a larger
28//! ripple across the keymap-dispatch and runtime borrow sites; this sum
29//! type is the pragmatic first tier — illegal field combinations are
30//! already truly-unrepresentable, and the transition surface is sealed
31//! behind methods so the typestate promotion is a later, localized change.
32
33extern crate self as escriba_mode;
34
35use escriba_core::{Mode, Operator};
36use escriba_memori::{CaretLine, CaretMove};
37use serde::{Deserialize, Serialize};
38
39/// Pending operator-pending state — only meaningful in [`ModalState::Normal`].
40///
41/// Holds the count prefix being accumulated (`5` in `5dd`) and the pending
42/// operator (`d` in `dw`). Both live HERE and only here: no insert-mode or
43/// command-mode value can carry them, because the only place the type
44/// system admits them is the `Normal` variant.
45#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
46pub struct PendingOp {
47 /// Accumulating count prefix (`None` ⇒ no count typed yet, effective 1).
48 pub count: Option<u32>,
49 /// Pending operator awaiting a motion (the `d` in `dw`).
50 pub operator: Option<Operator>,
51}
52
53/// The modal state machine — a SUM over the five editor modes, each
54/// carrying ONLY the data valid in that mode.
55///
56/// `#[serde(tag = "mode")]` keeps the wire shape readable (`{"mode":
57/// "Normal", "pending": {…}}`) and is the parse boundary: a deserialized
58/// value can only be one of the legal shapes.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
60#[serde(tag = "mode")]
61pub enum ModalState {
62 Normal {
63 pending: PendingOp,
64 },
65 Insert,
66 Visual,
67 VisualLine,
68 Command {
69 #[serde(flatten)]
70 line: ExLine,
71 },
72}
73
74impl Default for ModalState {
75 fn default() -> Self {
76 Self::Normal {
77 pending: PendingOp::default(),
78 }
79 }
80}
81
82/// The ex-line: [`CaretLine`] plus the wire shape `escriba-api` publishes.
83///
84/// The editing logic is NOT here — it is `CaretLine`, in memori, because the
85/// search prompt needs the identical thing and the two crates cannot see each
86/// other. What is left is the one thing that IS this crate's business: the
87/// published JSON says `minibuffer`, and a positioning primitive has no
88/// reason to know that word.
89///
90/// # Wire shape
91///
92/// `#[serde(flatten)]`ed into [`ModalState::Command`], so the schema reads
93/// `{"mode": "Command", "minibuffer": "wq", "caret": 1}` — unchanged by
94/// either the field-folding or the move down to memori.
95#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
96#[serde(from = "ExLineWire", into = "ExLineWire")]
97pub struct ExLine(#[schemars(with = "ExLineWire")] CaretLine);
98
99/// The serialization shadow of [`ExLine`] — and the parse boundary.
100///
101/// Private fields stop *code* from breaking `caret <= len`; they say nothing
102/// about a document that simply asserts `caret: 99` on a two-char line.
103/// `CaretLine::new` clamps, so an `ExLine` that exists is one that holds,
104/// whatever it was decoded from.
105#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
106struct ExLineWire {
107 #[serde(default)]
108 minibuffer: String,
109 #[serde(default)]
110 caret: usize,
111}
112
113impl From<ExLineWire> for ExLine {
114 fn from(w: ExLineWire) -> Self {
115 Self(CaretLine::new(w.minibuffer, w.caret))
116 }
117}
118
119impl From<ExLine> for ExLineWire {
120 fn from(l: ExLine) -> Self {
121 Self {
122 minibuffer: l.0.text().to_owned(),
123 caret: l.0.caret(),
124 }
125 }
126}
127
128impl ExLine {
129 /// The text typed so far, without the leading `:`.
130 #[must_use]
131 pub fn text(&self) -> &str {
132 self.0.text()
133 }
134
135 /// The caret, in chars from the start.
136 #[must_use]
137 pub const fn caret(&self) -> usize {
138 self.0.caret()
139 }
140
141 /// Insert a char AT the caret and step past it.
142 pub fn insert(&mut self, ch: char) {
143 self.0.insert(ch);
144 }
145
146 /// Append a raw fragment and park the caret at the end.
147 ///
148 /// Appends rather than inserting on purpose: its caller is the command
149 /// registry's `__quit__` sentinel handshake, writing a fragment the user
150 /// did not type.
151 pub fn push_str(&mut self, s: &str) {
152 self.0.push_str(s);
153 }
154
155 /// Move the caret.
156 pub fn move_caret(&mut self, to: CaretMove) {
157 self.0.move_caret(to);
158 }
159
160 /// Delete the char AT the caret (`<Del>`).
161 pub fn delete(&mut self) {
162 self.0.delete();
163 }
164
165 /// Delete the char BEFORE the caret (`<BS>`), returning it.
166 pub fn backspace(&mut self) -> Option<char> {
167 self.0.backspace()
168 }
169
170 /// Empty the line AND return the caret home.
171 pub fn clear(&mut self) {
172 self.0.clear();
173 }
174
175 /// Length in chars — the caret's upper bound.
176 #[must_use]
177 pub fn len_chars(&self) -> usize {
178 self.0.len_chars()
179 }
180}
181
182impl ModalState {
183 #[must_use]
184 pub fn new() -> Self {
185 Self::default()
186 }
187
188 /// The [`Mode`] discriminant of the current state — the projection the
189 /// renderers + keymap dispatch read.
190 #[must_use]
191 pub const fn mode(&self) -> Mode {
192 match self {
193 Self::Normal { .. } => Mode::Normal,
194 Self::Insert => Mode::Insert,
195 Self::Visual => Mode::Visual,
196 Self::VisualLine => Mode::VisualLine,
197 Self::Command { .. } => Mode::Command,
198 }
199 }
200
201 // ── Typed transitions — the ONLY way to change mode ──────────────────
202 //
203 // Each transition drops the data that is invalid in the destination
204 // mode by *construction*: entering `Insert` builds the `Insert` variant
205 // which has no field to hold a pending operator, so the operator is
206 // gone — not "cleared by a guard", but structurally absent.
207
208 /// Enter [`Mode::Normal`] with no pending count/operator.
209 pub fn enter_normal(&mut self) {
210 *self = Self::Normal {
211 pending: PendingOp::default(),
212 };
213 }
214
215 /// Enter [`Mode::Insert`]. Any pending count/operator/minibuffer is
216 /// dropped by construction.
217 pub fn enter_insert(&mut self) {
218 *self = Self::Insert;
219 }
220
221 /// Enter [`Mode::Visual`].
222 pub fn enter_visual(&mut self) {
223 *self = Self::Visual;
224 }
225
226 /// Enter [`Mode::VisualLine`].
227 pub fn enter_visual_line(&mut self) {
228 *self = Self::VisualLine;
229 }
230
231 /// Enter [`Mode::Command`] with an empty minibuffer.
232 pub fn enter_command(&mut self) {
233 *self = Self::Command {
234 line: ExLine::default(),
235 };
236 }
237
238 /// Leave any mode back to a clean [`Mode::Normal`] — the `<Esc>`
239 /// transition.
240 pub fn escape(&mut self) {
241 self.enter_normal();
242 }
243
244 /// Dispatch to the matching typed transition for a target [`Mode`].
245 ///
246 /// Kept for callers that hold a runtime `Mode` value (e.g. the keymap's
247 /// `Action::ChangeMode(Mode)`); it is sugar over the `enter_*` methods
248 /// and preserves the invariant the same way.
249 pub fn enter(&mut self, mode: Mode) {
250 match mode {
251 Mode::Normal => self.enter_normal(),
252 Mode::Insert => self.enter_insert(),
253 Mode::Visual => self.enter_visual(),
254 Mode::VisualLine => self.enter_visual_line(),
255 Mode::Command => self.enter_command(),
256 }
257 }
258
259 // ── Pending-op surface — no-ops outside Normal ───────────────────────
260 //
261 // These mutate the `Normal` variant's pending state. Called in any
262 // other mode they are silent no-ops: there is no field to mutate, so
263 // the count/operator concept simply does not exist there — which is the
264 // whole point of the sum type.
265
266 /// Set the pending operator. No-op unless in [`Mode::Normal`].
267 pub fn set_operator(&mut self, op: Operator) {
268 if let Self::Normal { pending } = self {
269 pending.operator = Some(op);
270 }
271 }
272
273 /// Append a digit to the pending count. No-op unless in [`Mode::Normal`].
274 pub fn append_count(&mut self, digit: u32) {
275 if let Self::Normal { pending } = self {
276 let n = pending.count.unwrap_or(0);
277 pending.count = Some(n.saturating_mul(10).saturating_add(digit));
278 }
279 }
280
281 /// The current pending count (`None` ⇒ none accumulated). Always `None`
282 /// outside [`Mode::Normal`].
283 #[must_use]
284 pub const fn pending_count(&self) -> Option<u32> {
285 match self {
286 Self::Normal { pending } => pending.count,
287 _ => None,
288 }
289 }
290
291 /// The current pending operator. Always `None` outside [`Mode::Normal`].
292 #[must_use]
293 pub const fn pending_operator(&self) -> Option<Operator> {
294 match self {
295 Self::Normal { pending } => pending.operator,
296 _ => None,
297 }
298 }
299
300 /// Take the pending count, leaving it cleared; defaults to 1 (and at
301 /// least 1). No-op-returning-1 outside [`Mode::Normal`].
302 #[must_use]
303 pub fn consume_count(&mut self) -> u32 {
304 match self {
305 Self::Normal { pending } => pending.count.take().unwrap_or(1).max(1),
306 _ => 1,
307 }
308 }
309
310 /// Clear any pending count without consuming its value.
311 pub fn clear_count(&mut self) {
312 if let Self::Normal { pending } = self {
313 pending.count = None;
314 }
315 }
316
317 /// Take the pending operator, leaving it cleared.
318 #[must_use]
319 pub fn consume_operator(&mut self) -> Option<Operator> {
320 match self {
321 Self::Normal { pending } => pending.operator.take(),
322 _ => None,
323 }
324 }
325
326 // ── Command minibuffer surface — no-ops outside Command ──────────────
327
328 /// Read the command-mode minibuffer (empty string outside
329 /// [`Mode::Command`]).
330 #[must_use]
331 pub fn minibuffer(&self) -> &str {
332 match self {
333 Self::Command { line } => line.text(),
334 _ => "",
335 }
336 }
337
338 /// Push a char onto the minibuffer. No-op unless in [`Mode::Command`].
339 pub fn push_minibuffer(&mut self, ch: char) {
340 if let Self::Command { line } = self {
341 line.insert(ch);
342 }
343 }
344
345 /// Move the ex-line caret. No-op outside [`Mode::Command`].
346 pub fn move_minibuffer_caret(&mut self, to: CaretMove) {
347 if let Self::Command { line } = self {
348 line.move_caret(to);
349 }
350 }
351
352 /// Delete the character AT the caret. No-op at the end of the line.
353 pub fn delete_minibuffer_at_caret(&mut self) {
354 if let Self::Command { line } = self {
355 line.delete();
356 }
357 }
358
359 /// The ex-line caret, in chars. `0` outside [`Mode::Command`].
360 #[must_use]
361 pub fn minibuffer_caret(&self) -> usize {
362 match self {
363 Self::Command { line } => line.caret(),
364 _ => 0,
365 }
366 }
367
368 /// Pop a char off the minibuffer. `None` unless in [`Mode::Command`].
369 pub fn pop_minibuffer(&mut self) -> Option<char> {
370 match self {
371 Self::Command { line } => line.backspace(),
372 _ => None,
373 }
374 }
375
376 /// Append a raw fragment to the minibuffer (used by the command
377 /// registry's `__quit__` sentinel handshake). No-op outside
378 /// [`Mode::Command`].
379 pub fn push_minibuffer_str(&mut self, s: &str) {
380 if let Self::Command { line } = self {
381 line.push_str(s);
382 }
383 }
384
385 /// Clear the minibuffer in place. No-op outside [`Mode::Command`].
386 pub fn clear_minibuffer(&mut self) {
387 if let Self::Command { line } = self {
388 line.clear();
389 }
390 }
391}
392
393/// vim's `{count}{operator}{count}{motion}` composition machine.
394///
395/// **Lifted down from `escriba-runtime` (0.1.68).** It only ever needed
396/// [`escriba_core`] + `zenmai`, and living in the runtime made it reachable
397/// only by depending on the whole editor — a rope, an LSP client, a plugin
398/// host and a regex engine — to compose two keystrokes. A consumer that wants
399/// vim key composition over something that is not a text editor (a picker
400/// query, a command palette, a filter box) can now have it for two light
401/// dependencies.
402///
403/// `escriba-runtime` re-exports both types from here, so
404/// `escriba_runtime::{OpState, OperatorPending}` keeps resolving and no
405/// consumer moves.
406mod operator_pending;
407pub use operator_pending::{OpState, OperatorPending};
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[test]
414 fn default_is_clean_normal() {
415 let s = ModalState::new();
416 assert_eq!(s.mode(), Mode::Normal);
417 assert_eq!(s.pending_count(), None);
418 assert_eq!(s.pending_operator(), None);
419 }
420
421 // ── Legal transitions work ───────────────────────────────────────────
422
423 #[test]
424 fn enter_transitions_set_mode() {
425 let mut s = ModalState::new();
426 s.enter_insert();
427 assert_eq!(s.mode(), Mode::Insert);
428 s.enter_visual();
429 assert_eq!(s.mode(), Mode::Visual);
430 s.enter_visual_line();
431 assert_eq!(s.mode(), Mode::VisualLine);
432 s.enter_command();
433 assert_eq!(s.mode(), Mode::Command);
434 s.escape();
435 assert_eq!(s.mode(), Mode::Normal);
436 }
437
438 #[test]
439 fn enter_by_mode_value_dispatches() {
440 for m in [
441 Mode::Normal,
442 Mode::Insert,
443 Mode::Visual,
444 Mode::VisualLine,
445 Mode::Command,
446 ] {
447 let mut s = ModalState::new();
448 s.enter(m);
449 assert_eq!(s.mode(), m);
450 }
451 }
452
453 // ── Illegal field combinations are UNREPRESENTABLE ───────────────────
454
455 #[test]
456 fn leaving_normal_structurally_drops_pending() {
457 // Set a count + operator in Normal, then enter Insert. The pending
458 // data is GONE — not because a guard cleared it, but because the
459 // `Insert` variant has no field that could hold it. There is no
460 // expressible `ModalState::Insert { pending_operator: … }`.
461 let mut s = ModalState::new();
462 s.set_operator(Operator::Delete);
463 s.append_count(5);
464 assert_eq!(s.pending_operator(), Some(Operator::Delete));
465 assert_eq!(s.pending_count(), Some(5));
466 s.enter_insert();
467 // The compiler refuses `Insert` carrying these; the accessors prove
468 // there is no value to read.
469 assert_eq!(s.pending_operator(), None);
470 assert_eq!(s.pending_count(), None);
471 }
472
473 #[test]
474 fn pending_ops_are_noops_outside_normal() {
475 // Attempting to set an operator / count while NOT in Normal cannot
476 // corrupt the state — the variants have nowhere to store them.
477 let mut s = ModalState::new();
478 s.enter_insert();
479 s.set_operator(Operator::Yank);
480 s.append_count(9);
481 assert_eq!(s.pending_operator(), None);
482 assert_eq!(s.pending_count(), None);
483 assert_eq!(s.consume_count(), 1, "no count exists outside Normal");
484 assert_eq!(s.consume_operator(), None);
485 }
486
487 #[test]
488 fn minibuffer_is_noop_outside_command() {
489 // An Insert-mode value cannot accumulate a command line — there is
490 // no minibuffer field on the `Insert` variant.
491 let mut s = ModalState::new();
492 s.enter_insert();
493 s.push_minibuffer('w');
494 assert_eq!(s.minibuffer(), "", "insert mode has no minibuffer");
495 assert_eq!(s.pop_minibuffer(), None);
496 }
497
498 #[test]
499 fn entering_command_clears_prior_minibuffer() {
500 let mut s = ModalState::new();
501 s.enter_command();
502 s.push_minibuffer('q');
503 assert_eq!(s.minibuffer(), "q");
504 // Re-entering command starts a fresh, empty line.
505 s.enter_command();
506 assert_eq!(s.minibuffer(), "");
507 }
508
509 // ── Behavioral round-trips (parity with the old product type) ────────
510
511 #[test]
512 fn normal_resets_pending_state() {
513 let mut s = ModalState::new();
514 s.enter_insert();
515 s.set_operator(Operator::Delete);
516 s.append_count(5);
517 s.enter_normal();
518 assert!(s.pending_count().is_none());
519 assert!(s.pending_operator().is_none());
520 }
521
522 #[test]
523 fn count_accumulates() {
524 let mut s = ModalState::new();
525 s.append_count(5);
526 s.append_count(3);
527 assert_eq!(s.consume_count(), 53);
528 assert_eq!(s.consume_count(), 1); // default 1 when consumed again
529 }
530
531 #[test]
532 fn operator_round_trip() {
533 let mut s = ModalState::new();
534 s.set_operator(Operator::Yank);
535 assert_eq!(s.consume_operator(), Some(Operator::Yank));
536 assert_eq!(s.consume_operator(), None);
537 }
538
539 #[test]
540 fn minibuffer_append_pop() {
541 let mut s = ModalState::new();
542 s.enter_command();
543 s.push_minibuffer('w');
544 assert_eq!(s.minibuffer(), "w");
545 assert_eq!(s.pop_minibuffer(), Some('w'));
546 }
547
548 #[test]
549 fn minibuffer_str_and_clear() {
550 let mut s = ModalState::new();
551 s.enter_command();
552 s.push_minibuffer_str("__quit__");
553 assert!(s.minibuffer().contains("__quit__"));
554 s.clear_minibuffer();
555 assert_eq!(s.minibuffer(), "");
556 }
557
558 /// The wire shape round-trips, and a deserialized value is one of the
559 /// legal variants only (the parse boundary rejects illegal shapes).
560 #[test]
561 fn serde_round_trip_per_variant() {
562 for s in [
563 ModalState::new(),
564 {
565 let mut n = ModalState::new();
566 n.append_count(12);
567 n.set_operator(Operator::Delete);
568 n
569 },
570 ModalState::Insert,
571 ModalState::Visual,
572 ModalState::VisualLine,
573 {
574 let mut c = ModalState::new();
575 c.enter_command();
576 c.push_minibuffer('x');
577 c
578 },
579 ] {
580 let json = serde_json::to_string(&s).unwrap();
581 let back: ModalState = serde_json::from_str(&json).unwrap();
582 assert_eq!(s, back);
583 }
584 }
585}
586
587#[cfg(test)]
588mod ex_line_tests {
589 use super::*;
590
591 #[test]
592 fn clearing_the_ex_line_brings_the_caret_home() {
593 // The defect that motivated `ExLine`: the text was emptied and the
594 // caret left behind, so the next insert built a line whose caret
595 // claimed a position the line did not have.
596 let mut s = ModalState::new();
597 s.enter_command();
598 for ch in "foo".chars() {
599 s.push_minibuffer(ch);
600 }
601 assert_eq!(s.minibuffer_caret(), 3);
602
603 s.clear_minibuffer();
604 assert_eq!(s.minibuffer(), "");
605 assert_eq!(s.minibuffer_caret(), 0, "the caret is half of the value");
606
607 s.push_minibuffer('x');
608 assert_eq!(s.minibuffer(), "x");
609 assert_eq!(
610 s.minibuffer_caret(),
611 1,
612 "and one char in means caret 1, not 4"
613 );
614 }
615
616 #[test]
617 fn the_caret_never_exceeds_the_line_it_indexes() {
618 // The invariant, exercised across every mutation the type offers.
619 let mut line = ExLine::default();
620 for ch in "héllo".chars() {
621 line.insert(ch);
622 }
623 line.move_caret(CaretMove::Start);
624 line.delete();
625 line.backspace();
626 line.move_caret(CaretMove::End);
627 line.push_str("!");
628 line.clear();
629 line.insert('a');
630 assert!(line.caret() <= line.len_chars());
631 assert_eq!(line.text(), "a");
632 }
633
634 #[test]
635 fn the_published_wire_shape_survives_the_extraction() {
636 // `escriba-api` publishes `ModalState`'s schema, so folding two fields
637 // into a struct must not be visible on the wire. This test is what
638 // makes `#[serde(flatten)]` + the `minibuffer` rename load-bearing
639 // rather than decorative.
640 let mut s = ModalState::new();
641 s.enter_command();
642 s.push_minibuffer('w');
643 s.push_minibuffer('q');
644 s.move_minibuffer_caret(CaretMove::Left);
645
646 let v: serde_json::Value = serde_json::to_value(&s).unwrap();
647 assert_eq!(v["mode"], "Command");
648 assert_eq!(v["minibuffer"], "wq", "NOT nested under a `line` key");
649 assert_eq!(v["caret"], 1);
650 assert_eq!(serde_json::from_value::<ModalState>(v).unwrap(), s);
651 }
652
653 #[test]
654 fn a_caret_past_the_end_is_clamped_at_the_parse_boundary() {
655 // Private fields stop code from breaking the invariant. They say
656 // nothing about a document that simply asserts a bad caret, which is
657 // what the deserialization shadow is for.
658 let s: ModalState =
659 serde_json::from_str(r#"{"mode":"Command","minibuffer":"ab","caret":99}"#).unwrap();
660 assert_eq!(s.minibuffer(), "ab");
661 assert_eq!(s.minibuffer_caret(), 2);
662 }
663}