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#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn default_is_clean_normal() {
399 let s = ModalState::new();
400 assert_eq!(s.mode(), Mode::Normal);
401 assert_eq!(s.pending_count(), None);
402 assert_eq!(s.pending_operator(), None);
403 }
404
405 // ── Legal transitions work ───────────────────────────────────────────
406
407 #[test]
408 fn enter_transitions_set_mode() {
409 let mut s = ModalState::new();
410 s.enter_insert();
411 assert_eq!(s.mode(), Mode::Insert);
412 s.enter_visual();
413 assert_eq!(s.mode(), Mode::Visual);
414 s.enter_visual_line();
415 assert_eq!(s.mode(), Mode::VisualLine);
416 s.enter_command();
417 assert_eq!(s.mode(), Mode::Command);
418 s.escape();
419 assert_eq!(s.mode(), Mode::Normal);
420 }
421
422 #[test]
423 fn enter_by_mode_value_dispatches() {
424 for m in [
425 Mode::Normal,
426 Mode::Insert,
427 Mode::Visual,
428 Mode::VisualLine,
429 Mode::Command,
430 ] {
431 let mut s = ModalState::new();
432 s.enter(m);
433 assert_eq!(s.mode(), m);
434 }
435 }
436
437 // ── Illegal field combinations are UNREPRESENTABLE ───────────────────
438
439 #[test]
440 fn leaving_normal_structurally_drops_pending() {
441 // Set a count + operator in Normal, then enter Insert. The pending
442 // data is GONE — not because a guard cleared it, but because the
443 // `Insert` variant has no field that could hold it. There is no
444 // expressible `ModalState::Insert { pending_operator: … }`.
445 let mut s = ModalState::new();
446 s.set_operator(Operator::Delete);
447 s.append_count(5);
448 assert_eq!(s.pending_operator(), Some(Operator::Delete));
449 assert_eq!(s.pending_count(), Some(5));
450 s.enter_insert();
451 // The compiler refuses `Insert` carrying these; the accessors prove
452 // there is no value to read.
453 assert_eq!(s.pending_operator(), None);
454 assert_eq!(s.pending_count(), None);
455 }
456
457 #[test]
458 fn pending_ops_are_noops_outside_normal() {
459 // Attempting to set an operator / count while NOT in Normal cannot
460 // corrupt the state — the variants have nowhere to store them.
461 let mut s = ModalState::new();
462 s.enter_insert();
463 s.set_operator(Operator::Yank);
464 s.append_count(9);
465 assert_eq!(s.pending_operator(), None);
466 assert_eq!(s.pending_count(), None);
467 assert_eq!(s.consume_count(), 1, "no count exists outside Normal");
468 assert_eq!(s.consume_operator(), None);
469 }
470
471 #[test]
472 fn minibuffer_is_noop_outside_command() {
473 // An Insert-mode value cannot accumulate a command line — there is
474 // no minibuffer field on the `Insert` variant.
475 let mut s = ModalState::new();
476 s.enter_insert();
477 s.push_minibuffer('w');
478 assert_eq!(s.minibuffer(), "", "insert mode has no minibuffer");
479 assert_eq!(s.pop_minibuffer(), None);
480 }
481
482 #[test]
483 fn entering_command_clears_prior_minibuffer() {
484 let mut s = ModalState::new();
485 s.enter_command();
486 s.push_minibuffer('q');
487 assert_eq!(s.minibuffer(), "q");
488 // Re-entering command starts a fresh, empty line.
489 s.enter_command();
490 assert_eq!(s.minibuffer(), "");
491 }
492
493 // ── Behavioral round-trips (parity with the old product type) ────────
494
495 #[test]
496 fn normal_resets_pending_state() {
497 let mut s = ModalState::new();
498 s.enter_insert();
499 s.set_operator(Operator::Delete);
500 s.append_count(5);
501 s.enter_normal();
502 assert!(s.pending_count().is_none());
503 assert!(s.pending_operator().is_none());
504 }
505
506 #[test]
507 fn count_accumulates() {
508 let mut s = ModalState::new();
509 s.append_count(5);
510 s.append_count(3);
511 assert_eq!(s.consume_count(), 53);
512 assert_eq!(s.consume_count(), 1); // default 1 when consumed again
513 }
514
515 #[test]
516 fn operator_round_trip() {
517 let mut s = ModalState::new();
518 s.set_operator(Operator::Yank);
519 assert_eq!(s.consume_operator(), Some(Operator::Yank));
520 assert_eq!(s.consume_operator(), None);
521 }
522
523 #[test]
524 fn minibuffer_append_pop() {
525 let mut s = ModalState::new();
526 s.enter_command();
527 s.push_minibuffer('w');
528 assert_eq!(s.minibuffer(), "w");
529 assert_eq!(s.pop_minibuffer(), Some('w'));
530 }
531
532 #[test]
533 fn minibuffer_str_and_clear() {
534 let mut s = ModalState::new();
535 s.enter_command();
536 s.push_minibuffer_str("__quit__");
537 assert!(s.minibuffer().contains("__quit__"));
538 s.clear_minibuffer();
539 assert_eq!(s.minibuffer(), "");
540 }
541
542 /// The wire shape round-trips, and a deserialized value is one of the
543 /// legal variants only (the parse boundary rejects illegal shapes).
544 #[test]
545 fn serde_round_trip_per_variant() {
546 for s in [
547 ModalState::new(),
548 {
549 let mut n = ModalState::new();
550 n.append_count(12);
551 n.set_operator(Operator::Delete);
552 n
553 },
554 ModalState::Insert,
555 ModalState::Visual,
556 ModalState::VisualLine,
557 {
558 let mut c = ModalState::new();
559 c.enter_command();
560 c.push_minibuffer('x');
561 c
562 },
563 ] {
564 let json = serde_json::to_string(&s).unwrap();
565 let back: ModalState = serde_json::from_str(&json).unwrap();
566 assert_eq!(s, back);
567 }
568 }
569}
570
571#[cfg(test)]
572mod ex_line_tests {
573 use super::*;
574
575 #[test]
576 fn clearing_the_ex_line_brings_the_caret_home() {
577 // The defect that motivated `ExLine`: the text was emptied and the
578 // caret left behind, so the next insert built a line whose caret
579 // claimed a position the line did not have.
580 let mut s = ModalState::new();
581 s.enter_command();
582 for ch in "foo".chars() {
583 s.push_minibuffer(ch);
584 }
585 assert_eq!(s.minibuffer_caret(), 3);
586
587 s.clear_minibuffer();
588 assert_eq!(s.minibuffer(), "");
589 assert_eq!(s.minibuffer_caret(), 0, "the caret is half of the value");
590
591 s.push_minibuffer('x');
592 assert_eq!(s.minibuffer(), "x");
593 assert_eq!(
594 s.minibuffer_caret(),
595 1,
596 "and one char in means caret 1, not 4"
597 );
598 }
599
600 #[test]
601 fn the_caret_never_exceeds_the_line_it_indexes() {
602 // The invariant, exercised across every mutation the type offers.
603 let mut line = ExLine::default();
604 for ch in "héllo".chars() {
605 line.insert(ch);
606 }
607 line.move_caret(CaretMove::Start);
608 line.delete();
609 line.backspace();
610 line.move_caret(CaretMove::End);
611 line.push_str("!");
612 line.clear();
613 line.insert('a');
614 assert!(line.caret() <= line.len_chars());
615 assert_eq!(line.text(), "a");
616 }
617
618 #[test]
619 fn the_published_wire_shape_survives_the_extraction() {
620 // `escriba-api` publishes `ModalState`'s schema, so folding two fields
621 // into a struct must not be visible on the wire. This test is what
622 // makes `#[serde(flatten)]` + the `minibuffer` rename load-bearing
623 // rather than decorative.
624 let mut s = ModalState::new();
625 s.enter_command();
626 s.push_minibuffer('w');
627 s.push_minibuffer('q');
628 s.move_minibuffer_caret(CaretMove::Left);
629
630 let v: serde_json::Value = serde_json::to_value(&s).unwrap();
631 assert_eq!(v["mode"], "Command");
632 assert_eq!(v["minibuffer"], "wq", "NOT nested under a `line` key");
633 assert_eq!(v["caret"], 1);
634 assert_eq!(serde_json::from_value::<ModalState>(v).unwrap(), s);
635 }
636
637 #[test]
638 fn a_caret_past_the_end_is_clamped_at_the_parse_boundary() {
639 // Private fields stop code from breaking the invariant. They say
640 // nothing about a document that simply asserts a bad caret, which is
641 // what the deserialization shadow is for.
642 let s: ModalState =
643 serde_json::from_str(r#"{"mode":"Command","minibuffer":"ab","caret":99}"#).unwrap();
644 assert_eq!(s.minibuffer(), "ab");
645 assert_eq!(s.minibuffer_caret(), 2);
646 }
647}