Skip to main content

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}