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 serde::{Deserialize, Serialize};
37
38/// Pending operator-pending state — only meaningful in [`ModalState::Normal`].
39///
40/// Holds the count prefix being accumulated (`5` in `5dd`) and the pending
41/// operator (`d` in `dw`). Both live HERE and only here: no insert-mode or
42/// command-mode value can carry them, because the only place the type
43/// system admits them is the `Normal` variant.
44#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
45pub struct PendingOp {
46 /// Accumulating count prefix (`None` ⇒ no count typed yet, effective 1).
47 pub count: Option<u32>,
48 /// Pending operator awaiting a motion (the `d` in `dw`).
49 pub operator: Option<Operator>,
50}
51
52/// The modal state machine — a SUM over the five editor modes, each
53/// carrying ONLY the data valid in that mode.
54///
55/// `#[serde(tag = "mode")]` keeps the wire shape readable (`{"mode":
56/// "Normal", "pending": {…}}`) and is the parse boundary: a deserialized
57/// value can only be one of the legal shapes.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
59#[serde(tag = "mode")]
60pub enum ModalState {
61 Normal { pending: PendingOp },
62 Insert,
63 Visual,
64 VisualLine,
65 Command { minibuffer: String },
66}
67
68impl Default for ModalState {
69 fn default() -> Self {
70 Self::Normal {
71 pending: PendingOp::default(),
72 }
73 }
74}
75
76impl ModalState {
77 #[must_use]
78 pub fn new() -> Self {
79 Self::default()
80 }
81
82 /// The [`Mode`] discriminant of the current state — the projection the
83 /// renderers + keymap dispatch read.
84 #[must_use]
85 pub const fn mode(&self) -> Mode {
86 match self {
87 Self::Normal { .. } => Mode::Normal,
88 Self::Insert => Mode::Insert,
89 Self::Visual => Mode::Visual,
90 Self::VisualLine => Mode::VisualLine,
91 Self::Command { .. } => Mode::Command,
92 }
93 }
94
95 // ── Typed transitions — the ONLY way to change mode ──────────────────
96 //
97 // Each transition drops the data that is invalid in the destination
98 // mode by *construction*: entering `Insert` builds the `Insert` variant
99 // which has no field to hold a pending operator, so the operator is
100 // gone — not "cleared by a guard", but structurally absent.
101
102 /// Enter [`Mode::Normal`] with no pending count/operator.
103 pub fn enter_normal(&mut self) {
104 *self = Self::Normal {
105 pending: PendingOp::default(),
106 };
107 }
108
109 /// Enter [`Mode::Insert`]. Any pending count/operator/minibuffer is
110 /// dropped by construction.
111 pub fn enter_insert(&mut self) {
112 *self = Self::Insert;
113 }
114
115 /// Enter [`Mode::Visual`].
116 pub fn enter_visual(&mut self) {
117 *self = Self::Visual;
118 }
119
120 /// Enter [`Mode::VisualLine`].
121 pub fn enter_visual_line(&mut self) {
122 *self = Self::VisualLine;
123 }
124
125 /// Enter [`Mode::Command`] with an empty minibuffer.
126 pub fn enter_command(&mut self) {
127 *self = Self::Command {
128 minibuffer: String::new(),
129 };
130 }
131
132 /// Leave any mode back to a clean [`Mode::Normal`] — the `<Esc>`
133 /// transition.
134 pub fn escape(&mut self) {
135 self.enter_normal();
136 }
137
138 /// Dispatch to the matching typed transition for a target [`Mode`].
139 ///
140 /// Kept for callers that hold a runtime `Mode` value (e.g. the keymap's
141 /// `Action::ChangeMode(Mode)`); it is sugar over the `enter_*` methods
142 /// and preserves the invariant the same way.
143 pub fn enter(&mut self, mode: Mode) {
144 match mode {
145 Mode::Normal => self.enter_normal(),
146 Mode::Insert => self.enter_insert(),
147 Mode::Visual => self.enter_visual(),
148 Mode::VisualLine => self.enter_visual_line(),
149 Mode::Command => self.enter_command(),
150 }
151 }
152
153 // ── Pending-op surface — no-ops outside Normal ───────────────────────
154 //
155 // These mutate the `Normal` variant's pending state. Called in any
156 // other mode they are silent no-ops: there is no field to mutate, so
157 // the count/operator concept simply does not exist there — which is the
158 // whole point of the sum type.
159
160 /// Set the pending operator. No-op unless in [`Mode::Normal`].
161 pub fn set_operator(&mut self, op: Operator) {
162 if let Self::Normal { pending } = self {
163 pending.operator = Some(op);
164 }
165 }
166
167 /// Append a digit to the pending count. No-op unless in [`Mode::Normal`].
168 pub fn append_count(&mut self, digit: u32) {
169 if let Self::Normal { pending } = self {
170 let n = pending.count.unwrap_or(0);
171 pending.count = Some(n.saturating_mul(10).saturating_add(digit));
172 }
173 }
174
175 /// The current pending count (`None` ⇒ none accumulated). Always `None`
176 /// outside [`Mode::Normal`].
177 #[must_use]
178 pub const fn pending_count(&self) -> Option<u32> {
179 match self {
180 Self::Normal { pending } => pending.count,
181 _ => None,
182 }
183 }
184
185 /// The current pending operator. Always `None` outside [`Mode::Normal`].
186 #[must_use]
187 pub const fn pending_operator(&self) -> Option<Operator> {
188 match self {
189 Self::Normal { pending } => pending.operator,
190 _ => None,
191 }
192 }
193
194 /// Take the pending count, leaving it cleared; defaults to 1 (and at
195 /// least 1). No-op-returning-1 outside [`Mode::Normal`].
196 #[must_use]
197 pub fn consume_count(&mut self) -> u32 {
198 match self {
199 Self::Normal { pending } => pending.count.take().unwrap_or(1).max(1),
200 _ => 1,
201 }
202 }
203
204 /// Clear any pending count without consuming its value.
205 pub fn clear_count(&mut self) {
206 if let Self::Normal { pending } = self {
207 pending.count = None;
208 }
209 }
210
211 /// Take the pending operator, leaving it cleared.
212 #[must_use]
213 pub fn consume_operator(&mut self) -> Option<Operator> {
214 match self {
215 Self::Normal { pending } => pending.operator.take(),
216 _ => None,
217 }
218 }
219
220 // ── Command minibuffer surface — no-ops outside Command ──────────────
221
222 /// Read the command-mode minibuffer (empty string outside
223 /// [`Mode::Command`]).
224 #[must_use]
225 pub fn minibuffer(&self) -> &str {
226 match self {
227 Self::Command { minibuffer } => minibuffer,
228 _ => "",
229 }
230 }
231
232 /// Push a char onto the minibuffer. No-op unless in [`Mode::Command`].
233 pub fn push_minibuffer(&mut self, ch: char) {
234 if let Self::Command { minibuffer } = self {
235 minibuffer.push(ch);
236 }
237 }
238
239 /// Pop a char off the minibuffer. `None` unless in [`Mode::Command`].
240 pub fn pop_minibuffer(&mut self) -> Option<char> {
241 match self {
242 Self::Command { minibuffer } => minibuffer.pop(),
243 _ => None,
244 }
245 }
246
247 /// Append a raw fragment to the minibuffer (used by the command
248 /// registry's `__quit__` sentinel handshake). No-op outside
249 /// [`Mode::Command`].
250 pub fn push_minibuffer_str(&mut self, s: &str) {
251 if let Self::Command { minibuffer } = self {
252 minibuffer.push_str(s);
253 }
254 }
255
256 /// Clear the minibuffer in place. No-op outside [`Mode::Command`].
257 pub fn clear_minibuffer(&mut self) {
258 if let Self::Command { minibuffer } = self {
259 minibuffer.clear();
260 }
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn default_is_clean_normal() {
270 let s = ModalState::new();
271 assert_eq!(s.mode(), Mode::Normal);
272 assert_eq!(s.pending_count(), None);
273 assert_eq!(s.pending_operator(), None);
274 }
275
276 // ── Legal transitions work ───────────────────────────────────────────
277
278 #[test]
279 fn enter_transitions_set_mode() {
280 let mut s = ModalState::new();
281 s.enter_insert();
282 assert_eq!(s.mode(), Mode::Insert);
283 s.enter_visual();
284 assert_eq!(s.mode(), Mode::Visual);
285 s.enter_visual_line();
286 assert_eq!(s.mode(), Mode::VisualLine);
287 s.enter_command();
288 assert_eq!(s.mode(), Mode::Command);
289 s.escape();
290 assert_eq!(s.mode(), Mode::Normal);
291 }
292
293 #[test]
294 fn enter_by_mode_value_dispatches() {
295 for m in [
296 Mode::Normal,
297 Mode::Insert,
298 Mode::Visual,
299 Mode::VisualLine,
300 Mode::Command,
301 ] {
302 let mut s = ModalState::new();
303 s.enter(m);
304 assert_eq!(s.mode(), m);
305 }
306 }
307
308 // ── Illegal field combinations are UNREPRESENTABLE ───────────────────
309
310 #[test]
311 fn leaving_normal_structurally_drops_pending() {
312 // Set a count + operator in Normal, then enter Insert. The pending
313 // data is GONE — not because a guard cleared it, but because the
314 // `Insert` variant has no field that could hold it. There is no
315 // expressible `ModalState::Insert { pending_operator: … }`.
316 let mut s = ModalState::new();
317 s.set_operator(Operator::Delete);
318 s.append_count(5);
319 assert_eq!(s.pending_operator(), Some(Operator::Delete));
320 assert_eq!(s.pending_count(), Some(5));
321 s.enter_insert();
322 // The compiler refuses `Insert` carrying these; the accessors prove
323 // there is no value to read.
324 assert_eq!(s.pending_operator(), None);
325 assert_eq!(s.pending_count(), None);
326 }
327
328 #[test]
329 fn pending_ops_are_noops_outside_normal() {
330 // Attempting to set an operator / count while NOT in Normal cannot
331 // corrupt the state — the variants have nowhere to store them.
332 let mut s = ModalState::new();
333 s.enter_insert();
334 s.set_operator(Operator::Yank);
335 s.append_count(9);
336 assert_eq!(s.pending_operator(), None);
337 assert_eq!(s.pending_count(), None);
338 assert_eq!(s.consume_count(), 1, "no count exists outside Normal");
339 assert_eq!(s.consume_operator(), None);
340 }
341
342 #[test]
343 fn minibuffer_is_noop_outside_command() {
344 // An Insert-mode value cannot accumulate a command line — there is
345 // no minibuffer field on the `Insert` variant.
346 let mut s = ModalState::new();
347 s.enter_insert();
348 s.push_minibuffer('w');
349 assert_eq!(s.minibuffer(), "", "insert mode has no minibuffer");
350 assert_eq!(s.pop_minibuffer(), None);
351 }
352
353 #[test]
354 fn entering_command_clears_prior_minibuffer() {
355 let mut s = ModalState::new();
356 s.enter_command();
357 s.push_minibuffer('q');
358 assert_eq!(s.minibuffer(), "q");
359 // Re-entering command starts a fresh, empty line.
360 s.enter_command();
361 assert_eq!(s.minibuffer(), "");
362 }
363
364 // ── Behavioral round-trips (parity with the old product type) ────────
365
366 #[test]
367 fn normal_resets_pending_state() {
368 let mut s = ModalState::new();
369 s.enter_insert();
370 s.set_operator(Operator::Delete);
371 s.append_count(5);
372 s.enter_normal();
373 assert!(s.pending_count().is_none());
374 assert!(s.pending_operator().is_none());
375 }
376
377 #[test]
378 fn count_accumulates() {
379 let mut s = ModalState::new();
380 s.append_count(5);
381 s.append_count(3);
382 assert_eq!(s.consume_count(), 53);
383 assert_eq!(s.consume_count(), 1); // default 1 when consumed again
384 }
385
386 #[test]
387 fn operator_round_trip() {
388 let mut s = ModalState::new();
389 s.set_operator(Operator::Yank);
390 assert_eq!(s.consume_operator(), Some(Operator::Yank));
391 assert_eq!(s.consume_operator(), None);
392 }
393
394 #[test]
395 fn minibuffer_append_pop() {
396 let mut s = ModalState::new();
397 s.enter_command();
398 s.push_minibuffer('w');
399 assert_eq!(s.minibuffer(), "w");
400 assert_eq!(s.pop_minibuffer(), Some('w'));
401 }
402
403 #[test]
404 fn minibuffer_str_and_clear() {
405 let mut s = ModalState::new();
406 s.enter_command();
407 s.push_minibuffer_str("__quit__");
408 assert!(s.minibuffer().contains("__quit__"));
409 s.clear_minibuffer();
410 assert_eq!(s.minibuffer(), "");
411 }
412
413 /// The wire shape round-trips, and a deserialized value is one of the
414 /// legal variants only (the parse boundary rejects illegal shapes).
415 #[test]
416 fn serde_round_trip_per_variant() {
417 for s in [
418 ModalState::new(),
419 {
420 let mut n = ModalState::new();
421 n.append_count(12);
422 n.set_operator(Operator::Delete);
423 n
424 },
425 ModalState::Insert,
426 ModalState::Visual,
427 ModalState::VisualLine,
428 {
429 let mut c = ModalState::new();
430 c.enter_command();
431 c.push_minibuffer('x');
432 c
433 },
434 ] {
435 let json = serde_json::to_string(&s).unwrap();
436 let back: ModalState = serde_json::from_str(&json).unwrap();
437 assert_eq!(s, back);
438 }
439 }
440}