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::{CaretMove, Chars, Offset, Ruler};
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 `:` line — its text and the caret editing it, as ONE value.
83///
84/// They are one value because they have an invariant *between* them —
85/// `caret <= text.chars().count()` — and a struct with private fields is the
86/// only place an invariant like that can be maintained once rather than at
87/// every mutation site.
88///
89/// That is not a hypothetical. The first version of the ex-line caret kept
90/// the two as sibling fields of the enum variant, and `clear_minibuffer`
91/// emptied the text while leaving the caret where it was. No test could see
92/// it, because a caret past the end is silently clamped by `Ruler` instead of
93/// panicking — the only report was `warning: unused variable: caret`, which
94/// is the compiler saying "you destructured the invariant's other half and
95/// did nothing with it".
96///
97/// # Wire shape
98///
99/// `#[serde(flatten)]`ed into `ModalState::Command`, and `text` is renamed to
100/// `minibuffer`, so the published schema is unchanged by this refactor:
101/// `{"mode": "Command", "minibuffer": "wq", "caret": 2}`. `escriba-api`
102/// publishes it, so the encapsulation is internal only.
103#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
104#[serde(from = "ExLineWire")]
105pub struct ExLine {
106 #[serde(rename = "minibuffer")]
107 text: String,
108 /// Where the next typed character goes, in CHARS from the start.
109 ///
110 /// Chars, never bytes — the ex-line is text a human edits, and a byte
111 /// caret lands mid-codepoint the first time someone types `:e héllo`.
112 /// Same reasoning, and same invariant, as the search prompt's caret.
113 #[serde(default)]
114 caret: usize,
115}
116
117/// The deserialization shadow of [`ExLine`].
118///
119/// Private fields stop *code* from breaking the invariant; they do nothing
120/// about a JSON document that simply asserts `caret: 99` on an empty line.
121/// Routing `Deserialize` through this shadow clamps at the parse boundary, so
122/// an `ExLine` that exists is an `ExLine` that holds — regardless of where it
123/// came from.
124#[derive(Deserialize, schemars::JsonSchema)]
125struct ExLineWire {
126 #[serde(default)]
127 minibuffer: String,
128 #[serde(default)]
129 caret: usize,
130}
131
132impl From<ExLineWire> for ExLine {
133 fn from(w: ExLineWire) -> Self {
134 let caret = w.caret.min(w.minibuffer.chars().count());
135 Self {
136 text: w.minibuffer,
137 caret,
138 }
139 }
140}
141
142impl ExLine {
143 /// The text typed so far, without the leading `:`.
144 #[must_use]
145 pub fn text(&self) -> &str {
146 &self.text
147 }
148
149 /// The caret, in chars from the start.
150 #[must_use]
151 pub const fn caret(&self) -> usize {
152 self.caret
153 }
154
155 /// Length in chars — the caret's upper bound.
156 #[must_use]
157 pub fn len_chars(&self) -> usize {
158 self.text.chars().count()
159 }
160
161 /// The caret as a BYTE index, for string surgery.
162 ///
163 /// The one place the ex-line turns chars into bytes, and it delegates to
164 /// `Ruler` rather than a local `char_indices().nth()` — which is what it
165 /// was for exactly one commit. That local version was the FOURTH
166 /// hand-rolled copy of this conversion in the workspace, written inside
167 /// the crate that had just taken a dependency on the vocabulary built to
168 /// hold it.
169 fn byte_of_caret(&self) -> usize {
170 Ruler::new(&self.text)
171 .to_bytes(Offset::<Chars>::new(self.caret))
172 .raw()
173 }
174
175 /// Insert a char AT the caret and step past it.
176 pub fn insert(&mut self, ch: char) {
177 let at = self.byte_of_caret();
178 self.text.insert(at, ch);
179 self.caret += 1;
180 }
181
182 /// Append a raw fragment and park the caret at the end.
183 ///
184 /// Appends rather than inserting on purpose: its caller is the command
185 /// registry's `__quit__` sentinel handshake, which is writing a fragment
186 /// the user did not type.
187 pub fn push_str(&mut self, s: &str) {
188 self.text.push_str(s);
189 self.caret = self.len_chars();
190 }
191
192 /// Move the caret.
193 pub fn move_caret(&mut self, to: CaretMove) {
194 self.caret = to.resolve(self.caret, self.len_chars());
195 }
196
197 /// Delete the char AT the caret (`<Del>`). No-op at the end of the line.
198 pub fn delete(&mut self) {
199 let at = self.byte_of_caret();
200 if at < self.text.len() {
201 self.text.remove(at);
202 }
203 }
204
205 /// Delete the char BEFORE the caret (`<BS>`), returning it.
206 ///
207 /// Deleting before the caret and deleting the tail are the same operation
208 /// only while the caret sits at the end — exactly the assumption that made
209 /// the search prompt's shadow diverge from the typed prompt.
210 pub fn backspace(&mut self) -> Option<char> {
211 if self.caret == 0 {
212 return None;
213 }
214 let at = self.byte_of_caret();
215 let prev = self.text[..at]
216 .char_indices()
217 .next_back()
218 .map_or(0, |(i, _)| i);
219 let ch = self.text.remove(prev);
220 self.caret -= 1;
221 Some(ch)
222 }
223
224 /// Empty the line AND return the caret home.
225 ///
226 /// Both halves, because they are one value. The version of this that
227 /// cleared only the text is what motivated the type.
228 pub fn clear(&mut self) {
229 self.text.clear();
230 self.caret = 0;
231 }
232}
233
234impl ModalState {
235 #[must_use]
236 pub fn new() -> Self {
237 Self::default()
238 }
239
240 /// The [`Mode`] discriminant of the current state — the projection the
241 /// renderers + keymap dispatch read.
242 #[must_use]
243 pub const fn mode(&self) -> Mode {
244 match self {
245 Self::Normal { .. } => Mode::Normal,
246 Self::Insert => Mode::Insert,
247 Self::Visual => Mode::Visual,
248 Self::VisualLine => Mode::VisualLine,
249 Self::Command { .. } => Mode::Command,
250 }
251 }
252
253 // ── Typed transitions — the ONLY way to change mode ──────────────────
254 //
255 // Each transition drops the data that is invalid in the destination
256 // mode by *construction*: entering `Insert` builds the `Insert` variant
257 // which has no field to hold a pending operator, so the operator is
258 // gone — not "cleared by a guard", but structurally absent.
259
260 /// Enter [`Mode::Normal`] with no pending count/operator.
261 pub fn enter_normal(&mut self) {
262 *self = Self::Normal {
263 pending: PendingOp::default(),
264 };
265 }
266
267 /// Enter [`Mode::Insert`]. Any pending count/operator/minibuffer is
268 /// dropped by construction.
269 pub fn enter_insert(&mut self) {
270 *self = Self::Insert;
271 }
272
273 /// Enter [`Mode::Visual`].
274 pub fn enter_visual(&mut self) {
275 *self = Self::Visual;
276 }
277
278 /// Enter [`Mode::VisualLine`].
279 pub fn enter_visual_line(&mut self) {
280 *self = Self::VisualLine;
281 }
282
283 /// Enter [`Mode::Command`] with an empty minibuffer.
284 pub fn enter_command(&mut self) {
285 *self = Self::Command {
286 line: ExLine::default(),
287 };
288 }
289
290 /// Leave any mode back to a clean [`Mode::Normal`] — the `<Esc>`
291 /// transition.
292 pub fn escape(&mut self) {
293 self.enter_normal();
294 }
295
296 /// Dispatch to the matching typed transition for a target [`Mode`].
297 ///
298 /// Kept for callers that hold a runtime `Mode` value (e.g. the keymap's
299 /// `Action::ChangeMode(Mode)`); it is sugar over the `enter_*` methods
300 /// and preserves the invariant the same way.
301 pub fn enter(&mut self, mode: Mode) {
302 match mode {
303 Mode::Normal => self.enter_normal(),
304 Mode::Insert => self.enter_insert(),
305 Mode::Visual => self.enter_visual(),
306 Mode::VisualLine => self.enter_visual_line(),
307 Mode::Command => self.enter_command(),
308 }
309 }
310
311 // ── Pending-op surface — no-ops outside Normal ───────────────────────
312 //
313 // These mutate the `Normal` variant's pending state. Called in any
314 // other mode they are silent no-ops: there is no field to mutate, so
315 // the count/operator concept simply does not exist there — which is the
316 // whole point of the sum type.
317
318 /// Set the pending operator. No-op unless in [`Mode::Normal`].
319 pub fn set_operator(&mut self, op: Operator) {
320 if let Self::Normal { pending } = self {
321 pending.operator = Some(op);
322 }
323 }
324
325 /// Append a digit to the pending count. No-op unless in [`Mode::Normal`].
326 pub fn append_count(&mut self, digit: u32) {
327 if let Self::Normal { pending } = self {
328 let n = pending.count.unwrap_or(0);
329 pending.count = Some(n.saturating_mul(10).saturating_add(digit));
330 }
331 }
332
333 /// The current pending count (`None` ⇒ none accumulated). Always `None`
334 /// outside [`Mode::Normal`].
335 #[must_use]
336 pub const fn pending_count(&self) -> Option<u32> {
337 match self {
338 Self::Normal { pending } => pending.count,
339 _ => None,
340 }
341 }
342
343 /// The current pending operator. Always `None` outside [`Mode::Normal`].
344 #[must_use]
345 pub const fn pending_operator(&self) -> Option<Operator> {
346 match self {
347 Self::Normal { pending } => pending.operator,
348 _ => None,
349 }
350 }
351
352 /// Take the pending count, leaving it cleared; defaults to 1 (and at
353 /// least 1). No-op-returning-1 outside [`Mode::Normal`].
354 #[must_use]
355 pub fn consume_count(&mut self) -> u32 {
356 match self {
357 Self::Normal { pending } => pending.count.take().unwrap_or(1).max(1),
358 _ => 1,
359 }
360 }
361
362 /// Clear any pending count without consuming its value.
363 pub fn clear_count(&mut self) {
364 if let Self::Normal { pending } = self {
365 pending.count = None;
366 }
367 }
368
369 /// Take the pending operator, leaving it cleared.
370 #[must_use]
371 pub fn consume_operator(&mut self) -> Option<Operator> {
372 match self {
373 Self::Normal { pending } => pending.operator.take(),
374 _ => None,
375 }
376 }
377
378 // ── Command minibuffer surface — no-ops outside Command ──────────────
379
380 /// Read the command-mode minibuffer (empty string outside
381 /// [`Mode::Command`]).
382 #[must_use]
383 pub fn minibuffer(&self) -> &str {
384 match self {
385 Self::Command { line } => line.text(),
386 _ => "",
387 }
388 }
389
390 /// Push a char onto the minibuffer. No-op unless in [`Mode::Command`].
391 pub fn push_minibuffer(&mut self, ch: char) {
392 if let Self::Command { line } = self {
393 line.insert(ch);
394 }
395 }
396
397 /// Move the ex-line caret. No-op outside [`Mode::Command`].
398 pub fn move_minibuffer_caret(&mut self, to: CaretMove) {
399 if let Self::Command { line } = self {
400 line.move_caret(to);
401 }
402 }
403
404 /// Delete the character AT the caret. No-op at the end of the line.
405 pub fn delete_minibuffer_at_caret(&mut self) {
406 if let Self::Command { line } = self {
407 line.delete();
408 }
409 }
410
411 /// The ex-line caret, in chars. `0` outside [`Mode::Command`].
412 #[must_use]
413 pub fn minibuffer_caret(&self) -> usize {
414 match self {
415 Self::Command { line } => line.caret(),
416 _ => 0,
417 }
418 }
419
420 /// Pop a char off the minibuffer. `None` unless in [`Mode::Command`].
421 pub fn pop_minibuffer(&mut self) -> Option<char> {
422 match self {
423 Self::Command { line } => line.backspace(),
424 _ => None,
425 }
426 }
427
428 /// Append a raw fragment to the minibuffer (used by the command
429 /// registry's `__quit__` sentinel handshake). No-op outside
430 /// [`Mode::Command`].
431 pub fn push_minibuffer_str(&mut self, s: &str) {
432 if let Self::Command { line } = self {
433 line.push_str(s);
434 }
435 }
436
437 /// Clear the minibuffer in place. No-op outside [`Mode::Command`].
438 pub fn clear_minibuffer(&mut self) {
439 if let Self::Command { line } = self {
440 line.clear();
441 }
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 #[test]
450 fn default_is_clean_normal() {
451 let s = ModalState::new();
452 assert_eq!(s.mode(), Mode::Normal);
453 assert_eq!(s.pending_count(), None);
454 assert_eq!(s.pending_operator(), None);
455 }
456
457 // ── Legal transitions work ───────────────────────────────────────────
458
459 #[test]
460 fn enter_transitions_set_mode() {
461 let mut s = ModalState::new();
462 s.enter_insert();
463 assert_eq!(s.mode(), Mode::Insert);
464 s.enter_visual();
465 assert_eq!(s.mode(), Mode::Visual);
466 s.enter_visual_line();
467 assert_eq!(s.mode(), Mode::VisualLine);
468 s.enter_command();
469 assert_eq!(s.mode(), Mode::Command);
470 s.escape();
471 assert_eq!(s.mode(), Mode::Normal);
472 }
473
474 #[test]
475 fn enter_by_mode_value_dispatches() {
476 for m in [
477 Mode::Normal,
478 Mode::Insert,
479 Mode::Visual,
480 Mode::VisualLine,
481 Mode::Command,
482 ] {
483 let mut s = ModalState::new();
484 s.enter(m);
485 assert_eq!(s.mode(), m);
486 }
487 }
488
489 // ── Illegal field combinations are UNREPRESENTABLE ───────────────────
490
491 #[test]
492 fn leaving_normal_structurally_drops_pending() {
493 // Set a count + operator in Normal, then enter Insert. The pending
494 // data is GONE — not because a guard cleared it, but because the
495 // `Insert` variant has no field that could hold it. There is no
496 // expressible `ModalState::Insert { pending_operator: … }`.
497 let mut s = ModalState::new();
498 s.set_operator(Operator::Delete);
499 s.append_count(5);
500 assert_eq!(s.pending_operator(), Some(Operator::Delete));
501 assert_eq!(s.pending_count(), Some(5));
502 s.enter_insert();
503 // The compiler refuses `Insert` carrying these; the accessors prove
504 // there is no value to read.
505 assert_eq!(s.pending_operator(), None);
506 assert_eq!(s.pending_count(), None);
507 }
508
509 #[test]
510 fn pending_ops_are_noops_outside_normal() {
511 // Attempting to set an operator / count while NOT in Normal cannot
512 // corrupt the state — the variants have nowhere to store them.
513 let mut s = ModalState::new();
514 s.enter_insert();
515 s.set_operator(Operator::Yank);
516 s.append_count(9);
517 assert_eq!(s.pending_operator(), None);
518 assert_eq!(s.pending_count(), None);
519 assert_eq!(s.consume_count(), 1, "no count exists outside Normal");
520 assert_eq!(s.consume_operator(), None);
521 }
522
523 #[test]
524 fn minibuffer_is_noop_outside_command() {
525 // An Insert-mode value cannot accumulate a command line — there is
526 // no minibuffer field on the `Insert` variant.
527 let mut s = ModalState::new();
528 s.enter_insert();
529 s.push_minibuffer('w');
530 assert_eq!(s.minibuffer(), "", "insert mode has no minibuffer");
531 assert_eq!(s.pop_minibuffer(), None);
532 }
533
534 #[test]
535 fn entering_command_clears_prior_minibuffer() {
536 let mut s = ModalState::new();
537 s.enter_command();
538 s.push_minibuffer('q');
539 assert_eq!(s.minibuffer(), "q");
540 // Re-entering command starts a fresh, empty line.
541 s.enter_command();
542 assert_eq!(s.minibuffer(), "");
543 }
544
545 // ── Behavioral round-trips (parity with the old product type) ────────
546
547 #[test]
548 fn normal_resets_pending_state() {
549 let mut s = ModalState::new();
550 s.enter_insert();
551 s.set_operator(Operator::Delete);
552 s.append_count(5);
553 s.enter_normal();
554 assert!(s.pending_count().is_none());
555 assert!(s.pending_operator().is_none());
556 }
557
558 #[test]
559 fn count_accumulates() {
560 let mut s = ModalState::new();
561 s.append_count(5);
562 s.append_count(3);
563 assert_eq!(s.consume_count(), 53);
564 assert_eq!(s.consume_count(), 1); // default 1 when consumed again
565 }
566
567 #[test]
568 fn operator_round_trip() {
569 let mut s = ModalState::new();
570 s.set_operator(Operator::Yank);
571 assert_eq!(s.consume_operator(), Some(Operator::Yank));
572 assert_eq!(s.consume_operator(), None);
573 }
574
575 #[test]
576 fn minibuffer_append_pop() {
577 let mut s = ModalState::new();
578 s.enter_command();
579 s.push_minibuffer('w');
580 assert_eq!(s.minibuffer(), "w");
581 assert_eq!(s.pop_minibuffer(), Some('w'));
582 }
583
584 #[test]
585 fn minibuffer_str_and_clear() {
586 let mut s = ModalState::new();
587 s.enter_command();
588 s.push_minibuffer_str("__quit__");
589 assert!(s.minibuffer().contains("__quit__"));
590 s.clear_minibuffer();
591 assert_eq!(s.minibuffer(), "");
592 }
593
594 /// The wire shape round-trips, and a deserialized value is one of the
595 /// legal variants only (the parse boundary rejects illegal shapes).
596 #[test]
597 fn serde_round_trip_per_variant() {
598 for s in [
599 ModalState::new(),
600 {
601 let mut n = ModalState::new();
602 n.append_count(12);
603 n.set_operator(Operator::Delete);
604 n
605 },
606 ModalState::Insert,
607 ModalState::Visual,
608 ModalState::VisualLine,
609 {
610 let mut c = ModalState::new();
611 c.enter_command();
612 c.push_minibuffer('x');
613 c
614 },
615 ] {
616 let json = serde_json::to_string(&s).unwrap();
617 let back: ModalState = serde_json::from_str(&json).unwrap();
618 assert_eq!(s, back);
619 }
620 }
621}
622
623#[cfg(test)]
624mod ex_line_tests {
625 use super::*;
626
627 #[test]
628 fn clearing_the_ex_line_brings_the_caret_home() {
629 // The defect that motivated `ExLine`: the text was emptied and the
630 // caret left behind, so the next insert built a line whose caret
631 // claimed a position the line did not have.
632 let mut s = ModalState::new();
633 s.enter_command();
634 for ch in "foo".chars() {
635 s.push_minibuffer(ch);
636 }
637 assert_eq!(s.minibuffer_caret(), 3);
638
639 s.clear_minibuffer();
640 assert_eq!(s.minibuffer(), "");
641 assert_eq!(s.minibuffer_caret(), 0, "the caret is half of the value");
642
643 s.push_minibuffer('x');
644 assert_eq!(s.minibuffer(), "x");
645 assert_eq!(
646 s.minibuffer_caret(),
647 1,
648 "and one char in means caret 1, not 4"
649 );
650 }
651
652 #[test]
653 fn the_caret_never_exceeds_the_line_it_indexes() {
654 // The invariant, exercised across every mutation the type offers.
655 let mut line = ExLine::default();
656 for ch in "héllo".chars() {
657 line.insert(ch);
658 }
659 line.move_caret(CaretMove::Start);
660 line.delete();
661 line.backspace();
662 line.move_caret(CaretMove::End);
663 line.push_str("!");
664 line.clear();
665 line.insert('a');
666 assert!(line.caret() <= line.len_chars());
667 assert_eq!(line.text(), "a");
668 }
669
670 #[test]
671 fn the_published_wire_shape_survives_the_extraction() {
672 // `escriba-api` publishes `ModalState`'s schema, so folding two fields
673 // into a struct must not be visible on the wire. This test is what
674 // makes `#[serde(flatten)]` + the `minibuffer` rename load-bearing
675 // rather than decorative.
676 let mut s = ModalState::new();
677 s.enter_command();
678 s.push_minibuffer('w');
679 s.push_minibuffer('q');
680 s.move_minibuffer_caret(CaretMove::Left);
681
682 let v: serde_json::Value = serde_json::to_value(&s).unwrap();
683 assert_eq!(v["mode"], "Command");
684 assert_eq!(v["minibuffer"], "wq", "NOT nested under a `line` key");
685 assert_eq!(v["caret"], 1);
686 assert_eq!(serde_json::from_value::<ModalState>(v).unwrap(), s);
687 }
688
689 #[test]
690 fn a_caret_past_the_end_is_clamped_at_the_parse_boundary() {
691 // Private fields stop code from breaking the invariant. They say
692 // nothing about a document that simply asserts a bad caret, which is
693 // what the deserialization shadow is for.
694 let s: ModalState =
695 serde_json::from_str(r#"{"mode":"Command","minibuffer":"ab","caret":99}"#).unwrap();
696 assert_eq!(s.minibuffer(), "ab");
697 assert_eq!(s.minibuffer_caret(), 2);
698 }
699}