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