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::{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}