Skip to main content

escriba_runtime/
lib.rs

1//! `escriba-runtime` — editor state machine.
2//!
3//! Wraps everything: `BufferSet`, `ModalState`, `Keymap`, `CommandRegistry`,
4//! `Layout`. Exposes `tick(input)` which advances one frame's worth of
5//! state given one input event. Pure — no rendering, no I/O beyond file
6//! save/load through `BufferSet`.
7
8extern crate self as escriba_runtime;
9
10mod plugin_host;
11pub use plugin_host::{LazyTrigger, PluginHost};
12
13mod operator_pending;
14pub mod status;
15
16pub use operator_pending::{OpState, OperatorPending};
17pub use status::{PromptKind, StatusModel};
18
19use std::collections::HashMap;
20
21use awase::KeyRepeatGate;
22use escriba_buffer::BufferSet;
23use escriba_buffer::TextRev;
24use escriba_command::CommandRegistry;
25use escriba_core::{
26    Action, Anchored, Bound, BufferId, Cursors, Damage, Edit, EditGen, HighlightEffect, JumpList,
27    Mode, Motion, Operator, Position, Range, TextEffect, WindowId,
28};
29use escriba_input::{InputOutcome, translate_app_event};
30use escriba_keymap::{Key, Keymap};
31use escriba_madoguchi::{Negai, Outcome};
32use escriba_mode::ModalState;
33use escriba_search::{Direction as SearchDirection, MatchCount, SearchState};
34use escriba_ui::chrome::{ChromePalette, FleetTheme};
35use escriba_ui::splash::Splash;
36use escriba_ui::{Layout, Viewport, Window};
37use escriba_vm::{EditorSnapshot, EscribaHost, EscribaVm, VmError};
38use madori::AppEvent;
39use std::time::Instant;
40
41/// Full editor state — the single Rust value the binary hands to the
42/// renderer each frame.
43pub struct EditorState {
44    pub buffers: BufferSet,
45    pub modal: ModalState,
46    /// Search session — the committed pattern, its matches, the live `/`
47    /// prompt and history. Owns no buffer or cursor; it answers questions
48    /// about text and this runtime applies the answers.
49    pub search: SearchState,
50    pub keymap: Keymap,
51    pub commands: CommandRegistry,
52    pub layout: Layout,
53    pub active: BufferId,
54    /// The single typed home for cursor state. Phase-1 holds one primary
55    /// [`Position`]; reads go through [`Self::cursor`], writes through
56    /// [`Self::set_cursor`] → [`Cursors::set_primary`]. There is no loose
57    /// `Position` field beside an unused multi-caret type to desync.
58    cursors: Cursors,
59    pub quit_requested: bool,
60    /// Messages surfaced to the user (status line / `:messages`) — the
61    /// sink for the tatara-lisp `(message …)` effect and other feedback.
62    pub messages: Vec<String>,
63    /// Which match the cursor last landed on (0-based) — the `[3/17]`
64    /// numerator, ANCHORED to the text revision it was computed against.
65    ///
66    /// The anchor is what removes the manual invalidation this field used to
67    /// need. An ordinal indexes a match set; when the text changes the set
68    /// changes underneath it and the number silently means something else.
69    /// Reading through `Anchored::get(current_rev)` makes that a `None`, so
70    /// forgetting to clear is no longer a thing that can be forgotten.
71    search_at: Option<Anchored<usize, TextRev>>,
72    /// The last text change, for `.`.
73    ///
74    /// An action plus whatever was typed while it held Insert open. Both
75    /// halves are needed: `cw` alone is not a change, it is the FIRST HALF of
76    /// one — the text that followed is the rest, and replaying without it
77    /// would delete a word and leave the buffer in Insert.
78    last_change: Option<LastChange>,
79    /// True while an insert session belonging to `last_change` is open, so
80    /// typed characters are appended to it. Cleared on leaving Insert.
81    recording_insert: bool,
82
83    /// Where the cursor was before each far jump — `<C-o>` / `<C-i>`.
84    /// Search commits, `n`/`N` and `*`/`#` all record into it, which is what
85    /// makes a search a place you can come back from.
86    pub jumps: JumpList,
87    /// Generic editor option store (name → value). Written by the
88    /// tatara-lisp `(set-option …)` effect and the declarative
89    /// `defoption` apply path; typed accessors layer on top later.
90    pub options: HashMap<String, String>,
91    /// Cached embedded tatara-lisp runtime, built lazily on first
92    /// `run_lisp`. Caching avoids re-installing the ~175-definition full
93    /// stdlib on every call; the interpreter's top-level env also
94    /// persists across calls, giving REPL-like session semantics (an
95    /// earlier `(define …)` is visible to a later `run_lisp`).
96    lisp_vm: Option<EscribaVm>,
97    /// Keys accumulated for an in-progress multi-key sequence — e.g.
98    /// holding `[,, f]` while waiting for the final key of
99    /// `<leader>ff`. Empty when not mid-sequence. Lives on
100    /// `EditorState` (not `ModalState`) so `escriba-mode` needn't
101    /// depend on `escriba-keymap`'s `Key`.
102    pub pending_keys: Vec<Key>,
103    /// Per-key debouncer for OS key-repeat storms. Holding `j`/`l` makes
104    /// the windowing system deliver one `KeyDown` per repeat tick
105    /// (~30-50ms); without a gate those flood the motion path and thrash
106    /// the viewport. The gate lets ONE event per `min_interval` (80ms
107    /// default — ~12 intentional taps/sec still pass) reach the editor in
108    /// the navigation modes. The fleet primitive (`awase::KeyRepeatGate`,
109    /// the same one mado uses) is reused — not reinvented.
110    repeat_gate: KeyRepeatGate<Key>,
111    /// Runtime lazy-activation host for USER plugin caixas (the bundled
112    /// default catalog is applied eagerly at boot, not through here).
113    /// A command / filetype-open / event fires the matching plugins'
114    /// entries through the escriba-lisp apply paths. See [`PluginHost`].
115    pub plugin_host: PluginHost,
116    /// The unnamed register — the home for text an operator yanks or
117    /// deletes (`Operator::leaves_register`). `None` until the first
118    /// register-leaving operator runs. Phase-1 holds the single unnamed
119    /// register; named registers (`"ay`) layer on later.
120    register: Option<String>,
121    /// The operator-pending FSM (`d`/`c`/`y` then a motion → `dw`/`c$`/`y0`),
122    /// standing on the fleet `zenmai` Mealy-machine primitive. Every dispatched
123    /// action passes through it; only an operator-then-motion pair is rewritten
124    /// into an [`Action::ApplyOperator`].
125    op_pending: zenmai::Stateful<OperatorPending>,
126    /// Monotonic refresh-generation stamp — the root of the sealed refresh
127    /// tree (`theory/ESCRIBA.md` §Refresh-Seal). Bumped on every applied
128    /// action + resize; the renderer gates on it so an idle frame does zero
129    /// re-highlight / re-shape, and a stale frame is unreachable.
130    edit_gen: EditGen,
131    /// The accumulated dirty region since the renderer last drained it (M1).
132    /// Only ever widened via [`Damage::join`] at the mutation funnel, so it
133    /// always covers the changed region (`Damage ⊇ changed`); the renderer
134    /// drains it with [`take_damage`](Self::take_damage) to scope its work.
135    damage: Damage,
136    /// The theme every face paints with.
137    ///
138    /// ONE owner. Before this, `(deftheme :preset …)` parsed, validated,
139    /// resolved to a real `FleetTheme` — and then nothing consumed it,
140    /// because each renderer called `ChromePalette::prescribed()` at every
141    /// paint site. The declaration was honoured on paper only. Holding it
142    /// here means a face reads the operator's theme the same way it reads
143    /// the cursor: from the state, per frame.
144    theme: FleetTheme,
145    /// `theme` resolved to concrete colours — cached because it is a plain
146    /// `Copy` struct read many times per frame, and re-derived only in
147    /// [`set_theme`](Self::set_theme), so the two cannot disagree.
148    chrome: ChromePalette,
149    /// How deep the current command dispatch is nested.
150    ///
151    /// `Negai::RunCommand` lets a command invoke a command, which is useful
152    /// and which can also recurse forever. The budget makes the runaway
153    /// bounded and REPORTED rather than a stack overflow — the difference
154    /// between a typed refusal and the editor dying under the operator.
155    dispatch_depth: u8,
156    /// Every live result list — diagnostics, hunks, grep hits, TODOs.
157    ///
158    /// Public so a producer outside the runtime can publish into it once the
159    /// courier lands; today the only producer is the marker scan.
160    pub results: escriba_shirube::ListRegistry,
161    /// Extension → language facts, populated from `(defmode …)`.
162    ///
163    /// The consumer `:commentstring` never had. Public so the binary's apply
164    /// pass can fill it the way it fills the keymap and the option store.
165    pub filetypes: escriba_core::FiletypeTable,
166    /// The start screen, while it is up.
167    ///
168    /// `Some` only between boot and the first keypress, and only when the
169    /// editor opened with no file. It is deliberately NOT a `Mode`: a mode
170    /// is a state keys are interpreted *in*, and the splash interprets
171    /// exactly one key before it is gone. Modelling it as `Option<Splash>`
172    /// keeps the modal state machine's variant set — and every exhaustive
173    /// match over it — untouched.
174    splash: Option<Splash>,
175}
176
177/// What the start screen did with a keypress.
178///
179/// Total, and matched exhaustively at its one call site, so a future
180/// outcome (a menu that opens a submenu, say) is a compile error rather
181/// than a key that silently falls through to the buffer.
182enum SplashKey {
183    /// No start screen is up — the key is the buffer's.
184    NotShowing,
185    /// The key selected a menu entry; run this.
186    Ran(Action),
187    /// The screen is gone and the key was not a menu key, so it still
188    /// means whatever it normally means. Anything else would make the
189    /// first keystroke after boot vanish.
190    Dismissed,
191}
192
193// ─── The counter, and the one place slips become mutations ───────────────
194
195/// `EditorState` read through the counter.
196///
197/// Borrowed, never copied: building it is free, so a command dispatch does
198/// not pay for a snapshot of the buffers.
199pub struct EditorWindow<'a> {
200    state: &'a EditorState,
201}
202
203impl escriba_madoguchi::CursorView for EditorWindow<'_> {
204    fn position(&self) -> Position {
205        self.state.cursor()
206    }
207    fn mode(&self) -> Mode {
208        self.state.modal.mode()
209    }
210}
211
212impl escriba_madoguchi::SyntaxView for EditorWindow<'_> {
213    fn filetype(&self) -> Option<&escriba_core::Filetype> {
214        let path = self.state.buffers.get(self.state.active)?.path.as_deref()?;
215        self.state.filetypes.resolve(path)
216    }
217}
218
219impl escriba_madoguchi::SearchView for EditorWindow<'_> {
220    fn pattern(&self) -> Option<&str> {
221        self.state.search.committed_pattern()
222    }
223    fn match_count(&self) -> Option<usize> {
224        // `None` means "nothing committed", which is not the same as zero
225        // matches — a distinction the status line already makes and that a
226        // handler must not have to re-derive.
227        self.state
228            .search
229            .committed_pattern()
230            .map(|_| self.state.search.match_count())
231    }
232    fn is_prompting(&self) -> bool {
233        self.state.search.is_prompting()
234    }
235}
236
237impl escriba_madoguchi::Snapshot for EditorWindow<'_> {
238    fn active(&self) -> Option<&dyn escriba_madoguchi::BufferView> {
239        self.buffer(self.state.active)
240    }
241    fn buffer(&self, id: BufferId) -> Option<&dyn escriba_madoguchi::BufferView> {
242        self.state
243            .buffers
244            .get(id)
245            .map(|b| b as &dyn escriba_madoguchi::BufferView)
246    }
247    fn buffer_ids(&self) -> Vec<BufferId> {
248        self.state.buffers.ids()
249    }
250    fn cursor(&self) -> &dyn escriba_madoguchi::CursorView {
251        self
252    }
253    fn option(&self, name: &str) -> Option<&str> {
254        self.state.options.get(name).map(String::as_str)
255    }
256    fn search(&self) -> &dyn escriba_madoguchi::SearchView {
257        self
258    }
259    fn syntax(&self) -> &dyn escriba_madoguchi::SyntaxView {
260        self
261    }
262}
263
264impl EditorState {
265    /// A read-only window onto this editor.
266    #[must_use]
267    pub fn window(&self) -> EditorWindow<'_> {
268        EditorWindow { state: self }
269    }
270
271    /// Honour an [`Outcome`] — the ONLY place slips become mutations.
272    ///
273    /// Every `&mut self` in the dispatch path lives here. A command cannot
274    /// reach editor state, so if the editor ends up in a state nobody
275    /// designed, this function is where it happened; that narrowing is the
276    /// whole return on the seam.
277    ///
278    /// A failed outcome's slips are DROPPED rather than half-applied: a
279    /// handler that reported failure has no business also mutating, and
280    /// applying part of what it asked for is how an editor reaches a state
281    /// nobody designed.
282    pub fn interpret(&mut self, outcome: Outcome) {
283        if let Some(m) = outcome.verdict.message() {
284            self.messages.push(m.to_string());
285            self.damage = self.damage.join(Damage::Viewport);
286            self.bump_gen();
287        }
288        if outcome.verdict.is_failure() {
289            return;
290        }
291        for slip in outcome.slips {
292            self.honour(slip);
293        }
294    }
295
296    /// Lower an [`Action`] to slips, when it has an exact slip equivalent.
297    ///
298    /// `None` means "editor mechanics" — 23 of the 30 variants are prompt
299    /// editing, the operator-pending FSM, motion resolution, the jumplist,
300    /// the dot register. Those are the KEYMAP's vocabulary, not the AUTHORED
301    /// one, and forcing them into `Negai` would put `PromptClearToStart` and
302    /// `SearchPreviewStep` in front of every plugin author and make the
303    /// capability question meaningless (what capability does a caret move
304    /// read?). One type serving two vocabularies is the mistake this avoids.
305    ///
306    /// The plan's M3 predicate was "apply_resolved contains zero `self.`
307    /// mutations", which would have forced exactly that. Amended: the
308    /// invariant worth having is ONE IMPLEMENTATION PER MUTATION, not one
309    /// vocabulary. See docs/backlog-plan.md §V Phase 1.
310    fn lower(action: &Action, active: BufferId) -> Option<Vec<Negai>> {
311        Some(match action {
312            Action::Quit => vec![Negai::Quit],
313            Action::ClearSearchHighlight => vec![Negai::ClearSearchHighlight],
314            Action::Save => vec![Negai::Save { buffer: active }],
315            Action::Undo => vec![Negai::Undo { buffer: active }],
316            Action::Redo => vec![Negai::Redo { buffer: active }],
317            // `apply_edit` was a STUB that did nothing, so this action was a
318            // silent no-op while `Negai::Edit` applied for real. Lowering it
319            // makes keymap-originated edits work for the first time — and
320            // nothing binds it today, so the duplication goes away at zero
321            // risk.
322            Action::Edit(edit) => vec![Negai::Edit {
323                buffer: active,
324                edit: edit.clone(),
325            }],
326            _ => return None,
327        })
328    }
329
330    /// What the world currently is, for freshness.
331    ///
332    /// One text axis per open buffer. A list sealed against this is fresh
333    /// exactly while the buffers it depends on are unchanged — and a buffer
334    /// that has since CLOSED drops out, which makes lists about it stale
335    /// rather than silently kept.
336    #[must_use]
337    pub fn world(&self) -> escriba_shirube::Anchor {
338        let mut a = escriba_shirube::Anchor::new();
339        for id in self.buffers.ids() {
340            if let Some(b) = self.buffers.get(id) {
341                a = a.on(escriba_shirube::Axis::Text(id, b.text_rev()));
342            }
343        }
344        a
345    }
346
347    /// Move the cursor to the next/previous finding in `list`.
348    ///
349    /// Reports the wrap, because `n`/`N` do and a reader losing their place
350    /// in a long file is the same problem either way.
351    fn walk_list(&mut self, list: &str, forward: bool) {
352        let world = self.world();
353        let Some(result) = self.results.get(list) else {
354            let mut m = String::from("no list named ");
355            m.push_str(list);
356            self.messages.push(m);
357            return;
358        };
359        if result.is_stale(&world) {
360            self.messages
361                .push("that list is out of date — run it again".to_string());
362            return;
363        }
364        let here = self.cursor().line;
365        let Some(found) = result.step(&world, here, forward, escriba_shirube::Bound::Exclusive)
366        else {
367            let mut m = String::from("no entries in ");
368            m.push_str(list);
369            self.messages.push(m);
370            return;
371        };
372        let to = found.site.range.start;
373        let msg = found.message.clone();
374        // A far jump, so it belongs in the jumplist — `<C-o>` must come back
375        // from a `]t` exactly as it comes back from an `n`.
376        self.jumps.push(self.cursor());
377        let clamped = self.buffers.get(self.active).map_or(to, |b| b.clamp(to));
378        self.set_cursor(clamped);
379        self.messages.push(msg);
380    }
381
382    /// Close a buffer, keeping "there is always an active buffer" true.
383    ///
384    /// The invariant is the whole reason this is not just
385    /// `self.buffers.close(id)`. `EditorState::active` is a `BufferId`, not
386    /// an `Option`, so a dangling active is not a degraded state — it is a
387    /// state where every read of the active buffer returns `None` and the
388    /// editor renders `<no buffer>` forever. Closing the last buffer opens a
389    /// scratch rather than emptying the set, which is what vim's `:bd` does
390    /// and what the type demands.
391    fn close_buffer(&mut self, id: BufferId) {
392        if self.buffers.close(id).is_none() {
393            self.messages.push("no such buffer".to_string());
394            return;
395        }
396        if self.active != id {
397            return;
398        }
399        // The active buffer went. Prefer the next one by id so repeated
400        // closes walk forward predictably rather than jumping around.
401        let next = self.buffers.ids().into_iter().find(|b| *b > id);
402        self.active = match next.or_else(|| self.buffers.ids().into_iter().next_back()) {
403            Some(b) => b,
404            None => self.buffers.scratch(""),
405        };
406        self.set_cursor(Position::ZERO);
407        if let Some(w) = self
408            .layout
409            .windows
410            .iter_mut()
411            .find(|w| w.id == self.layout.active)
412        {
413            w.buffer_id = self.active;
414        }
415    }
416
417    /// Move to the next or previous buffer, wrapping.
418    fn cycle_buffer(&mut self, forward: bool) {
419        let ids = self.buffers.ids();
420        if ids.len() < 2 {
421            self.messages.push("only one buffer".to_string());
422            return;
423        }
424        let at = ids.iter().position(|b| *b == self.active).unwrap_or(0);
425        let next = if forward {
426            (at + 1) % ids.len()
427        } else {
428            (at + ids.len() - 1) % ids.len()
429        };
430        self.active = ids[next];
431        self.set_cursor(Position::ZERO);
432        if let Some(w) = self
433            .layout
434            .windows
435            .iter_mut()
436            .find(|w| w.id == self.layout.active)
437        {
438            w.buffer_id = self.active;
439        }
440    }
441
442    /// Re-clamp the cursor and re-contain the viewport after a buffer
443    /// mutation.
444    ///
445    /// An undo can SHRINK the buffer under a cursor that was legal a moment
446    /// ago, leaving it out of bounds and its viewport scrolled past the end.
447    /// The Action executor has always done this (`self.set_cursor(self.cursor())`
448    /// after undo/redo/save); the M1 interpreter did NOT, so `u` re-followed
449    /// and `:undo` did not — two implementations of one operation, already
450    /// drifted within one milestone of being written. Naming it once is the
451    /// fix; lowering the Action arms onto the same slips is what keeps it
452    /// fixed.
453    fn refollow(&mut self) {
454        self.set_cursor(self.cursor());
455    }
456
457    /// Apply one slip and record what it damaged.
458    ///
459    /// The bookkeeping wrapper. The Action executor calls
460    /// [`honour_one`](Self::honour_one) directly because it does its own,
461    /// wider bookkeeping (the dot register, the S3 damage seal) around a
462    /// whole action.
463    fn honour(&mut self, slip: Negai) {
464        let touches_text = slip.touches_text();
465        self.honour_one(slip);
466        self.damage = self.damage.join(if touches_text {
467            Damage::Full
468        } else {
469            Damage::Viewport
470        });
471        self.bump_gen();
472    }
473
474    /// Apply one slip. THE single implementation of every mutation a slip
475    /// can ask for.
476    ///
477    /// Total over `Negai`: a new request variant is a compile error here
478    /// rather than a request silently ignored — the same failure Phase 0
479    /// removed one layer up.
480    fn honour_one(&mut self, slip: Negai) {
481        match slip {
482            Negai::Edit { buffer, edit } => {
483                if let Some(b) = self.buffers.get_mut(buffer) {
484                    let _ = b.apply(&edit);
485                }
486                self.refollow();
487            }
488            Negai::SetCursor { buffer, to } => {
489                // Clamping is the interpreter's job, exactly so that no
490                // handler has to re-implement it and get it wrong.
491                let clamped = self.buffers.get(buffer).map_or(to, |b| b.clamp(to));
492                self.set_cursor(clamped);
493            }
494            Negai::EnterMode(m) => self.modal.enter(m),
495            Negai::CycleBuffer { forward } => self.cycle_buffer(forward),
496            Negai::FocusBuffer(id) => {
497                if self.buffers.get(id).is_some() {
498                    self.active = id;
499                }
500            }
501            Negai::OpenPath(path) => match self.buffers.open(&path) {
502                Ok(id) => self.active = id,
503                Err(e) => self.messages.push(e.to_string()),
504            },
505            Negai::CloseBuffer(id) => self.close_buffer(id),
506            Negai::Save { buffer } => {
507                if let Some(b) = self.buffers.get_mut(buffer) {
508                    if let Err(e) = b.save() {
509                        self.messages.push(e.to_string());
510                    }
511                }
512                self.refollow();
513            }
514            Negai::Undo { buffer } => {
515                if let Some(b) = self.buffers.get_mut(buffer) {
516                    let _ = b.undo();
517                }
518                self.refollow();
519            }
520            Negai::Redo { buffer } => {
521                if let Some(b) = self.buffers.get_mut(buffer) {
522                    let _ = b.redo();
523                }
524                self.refollow();
525            }
526            Negai::Yank { text, .. } => self.register = Some(text),
527            Negai::ClearSearchHighlight => self.search.clear_highlight(),
528            Negai::SetOption { name, value } => {
529                self.options.insert(name, value);
530            }
531            Negai::InsertText(text) => self.insert_text(&text),
532            Negai::RunCommand { name, args } => self.run_command(&name, &args),
533            Negai::PublishFindings { list, findings } => {
534                let world = self.world();
535                self.results
536                    .publish(list, escriba_shirube::ResultList::new(findings, world));
537            }
538            Negai::WalkList { list, forward } => self.walk_list(&list, forward),
539            Negai::Message(m) => self.messages.push(m),
540            Negai::Quit => self.quit_requested = true,
541            // Both suspend the dispatch and need machinery that does not
542            // exist yet — the courier (Phase 5) and the AwaitKey resume
543            // (M3). Announced, never silently dropped: a slip that vanishes
544            // is the class Phase 0 sealed.
545            Negai::Errand(_) | Negai::AwaitKey { .. } => {
546                self.messages
547                    .push("deferred work is not wired yet".to_string());
548            }
549        }
550    }
551}
552
553/// Outcome of feeding one key to the multi-key pending-stroke loop.
554enum SeqStep {
555    /// Key consumed into an in-progress sequence; wait for the next.
556    Pending,
557    /// A full bound sequence resolved — run this action.
558    Resolved(Action),
559    /// Key is not part of any sequence; hand it to single-key dispatch.
560    Passthrough,
561}
562
563/// Keys whose HELD repeat is a viewport storm, and which the repeat gate
564/// therefore exists to debounce.
565///
566/// This is an ALLOW-LIST, and it used to be the complement — an exception list
567/// of "discrete" keys that grew three times (`n`/`N`/`*`/`#`, then `.`/`u`/
568/// `<C-r>`, then `/`/`?`/`:`), each time because a key had been silently
569/// swallowed and someone noticed. The third growth is the signal that the
570/// default was backwards: almost every key in a modal editor is a discrete,
571/// deliberate press, and only a handful are ones you HOLD.
572///
573/// Inverting it makes the failure mode safe. Forgetting to list a key here now
574/// means it is ungated — one extra keypress honoured — instead of silently
575/// dropped, and a dropped key is indistinguishable from a dead one.
576///
577/// Measured cost of the old direction: `/foo<CR>` then `/<CR>` (vim's
578/// reuse-the-previous-pattern) lost the second `/` outright, because the gate
579/// is keyed by KEY and the two presses fell inside one debounce window.
580const fn is_repeat_storm_candidate(key: &Key) -> bool {
581    matches!(
582        key,
583        // The four navigation keys a user actually holds down. `h`/`l` and
584        // `j`/`k` flood the motion path and thrash the viewport; everything
585        // else is pressed once and meant once.
586        Key::Char('h')
587            | Key::Char('j')
588            | Key::Char('k')
589            | Key::Char('l')
590            | Key::Left
591            | Key::Right
592            | Key::Up
593            | Key::Down
594    )
595}
596
597/// Turn a command failure into the sentence an operator should read.
598///
599/// The two failures mean genuinely different things and must not be reported
600/// the same way:
601///
602/// - `:flurb` — the operator typed a name that does not exist. "command not
603///   found" is exactly right; it says *you* made a typo.
604/// - `<leader>ff` bound to `picker.files` — escriba's OWN shipped config
605///   declares this, `--list-rc` counts it, and it is not built yet. Telling
606///   the operator "command not found" blames them for a gap we shipped.
607///
608/// The discriminator is the dotted form. `:action` takes action SYMBOLS
609/// (`picker.files`), never command names — that boundary is already pinned by
610/// `action_naming_a_command_is_inert_not_recursive` in escriba-command. So a
611/// dotted name that reached dispatch and resolved to nothing is a declared
612/// capability with no implementation, which is precisely what the 85 entries
613/// in `escriba/tests/action_resolution.rs` are.
614fn describe_command_failure(name: &str, e: &escriba_command::CommandError) -> String {
615    use escriba_command::CommandError as E;
616    match e {
617        // Already the right words — the registry knew it was declared.
618        E::Unhandled(_) => e.to_string(),
619        E::NotFound(n) if n.contains('.') => {
620            let mut m = String::with_capacity(n.len() + 48);
621            m.push('`');
622            m.push_str(n);
623            m.push_str("` is declared but not implemented yet");
624            m
625        }
626        _ => {
627            let _ = name;
628            e.to_string()
629        }
630    }
631}
632
633/// What committing the open search prompt did.
634///
635/// The two commit paths — bare `/` and operated `d/` — used to own private
636/// copies of the whole sequence (read origin+skip, `accept`, three-arm
637/// match, `commit_step_skipping`), and they drifted: the operated one
638/// never reported the wrap, so `d/foo<CR>` that wrapped the file was
639/// silent where `/foo<CR>` printed "search hit BOTTOM, continuing at TOP".
640///
641/// Total, and matched exhaustively at BOTH call sites, so a new outcome is
642/// a compile error in two places rather than a case one path quietly
643/// forgets. It does not make divergence impossible — the two paths
644/// genuinely differ at the landing step — it makes FORGETTING A CASE
645/// impossible, which is the failure that actually happened.
646enum CommitOutcome {
647    /// The prompt committed and a match was found.
648    Landed {
649        origin: usize,
650        step: escriba_search::Step,
651    },
652    /// Committed, but nothing matched. E486 already reported.
653    NotFound,
654    /// Nothing typed and no previous pattern. E35 already reported.
655    NoPrevious,
656    /// No prompt was open.
657    NoPrompt,
658}
659
660/// A replayable text change.
661#[derive(Debug, Clone)]
662struct LastChange {
663    /// The action that began the change.
664    action: Action,
665    /// How many times it ran.
666    count: u32,
667    /// Characters typed while the change held Insert mode open.
668    inserted: String,
669}
670
671impl EditorState {
672    /// Build a fresh editor with one buffer (scratch or file-backed).
673    pub fn new_with_buffer(initial: BufferSet, active: BufferId) -> Self {
674        let window = Window {
675            id: WindowId(1),
676            buffer_id: active,
677            viewport: Viewport {
678                top_line: 0,
679                left_column: 0,
680                visible_lines: 40,
681                visible_columns: 160,
682            },
683        };
684        Self {
685            buffers: initial,
686            modal: ModalState::new(),
687            search: SearchState::new(escriba_search::CaseMode::Smart),
688            search_at: None,
689            last_change: None,
690            recording_insert: false,
691            jumps: JumpList::new(),
692            keymap: Keymap::default_vim(),
693            commands: CommandRegistry::default_set(),
694            layout: Layout::single(window),
695            active,
696            cursors: Cursors::single(Position::ZERO),
697            quit_requested: false,
698            register: None,
699            op_pending: zenmai::Stateful::new(OpState::Resting),
700            messages: Vec::new(),
701            options: HashMap::new(),
702            lisp_vm: None,
703            pending_keys: Vec::new(),
704            repeat_gate: KeyRepeatGate::new(),
705            plugin_host: PluginHost::default(),
706            edit_gen: EditGen::default(),
707            damage: Damage::None,
708            // The FLEET default until an rc says otherwise — never a
709            // hand-written theme name, so a fleet re-point lands for free.
710            dispatch_depth: 0,
711            filetypes: escriba_core::FiletypeTable::new(),
712            results: escriba_shirube::ListRegistry::new(),
713            theme: FleetTheme::prescribed_default(),
714            chrome: ChromePalette::prescribed(),
715            splash: None,
716        }
717    }
718
719    /// The theme this editor is set to.
720    #[must_use]
721    pub const fn theme(&self) -> FleetTheme {
722        self.theme
723    }
724
725    /// The colours every face paints with — read once per frame.
726    #[must_use]
727    pub const fn chrome(&self) -> ChromePalette {
728        self.chrome
729    }
730
731    /// Point the editor at a theme. The wiring that makes
732    /// `(deftheme :preset …)` real.
733    ///
734    /// Bumps the refresh generation, because a theme change repaints
735    /// everything: the GPU face caches its shaped buffer against that
736    /// generation and would otherwise keep the old colours until an
737    /// unrelated edit happened to invalidate it.
738    pub fn set_theme(&mut self, theme: FleetTheme) {
739        if self.theme == theme {
740            return;
741        }
742        self.theme = theme;
743        self.chrome = ChromePalette::for_theme(theme);
744        self.damage = self.damage.join(Damage::Viewport);
745        self.bump_gen();
746    }
747
748    /// The start screen, if one is up. Renderers paint this INSTEAD of the
749    /// buffer pane; `None` is the ordinary editor.
750    #[must_use]
751    pub fn splash(&self) -> Option<&Splash> {
752        self.splash.as_ref()
753    }
754
755    /// Raise the start screen. The binary calls this at boot when no file
756    /// was named; an empty splash is refused so a face never has to render
757    /// a blank screen over a perfectly good buffer.
758    pub fn set_splash(&mut self, splash: Splash) {
759        if splash.is_empty() {
760            return;
761        }
762        self.splash = Some(splash);
763        self.damage = self.damage.join(Damage::Viewport);
764        self.bump_gen();
765    }
766
767    /// Take the start screen down. Idempotent; bumps the refresh generation
768    /// only when something actually changed, so dismissing twice does not
769    /// cost a repaint.
770    pub fn dismiss_splash(&mut self) {
771        if self.splash.take().is_some() {
772            self.damage = self.damage.join(Damage::Viewport);
773            self.bump_gen();
774        }
775    }
776
777    /// Offer `key` to the start screen.
778    ///
779    /// A menu key runs its entry; ANY other key simply takes the screen
780    /// down and is then handled normally — so the first thing an operator
781    /// types is never swallowed.
782    fn consume_splash_key(&mut self, key: &Key) -> SplashKey {
783        let Some(splash) = self.splash.as_ref() else {
784            return SplashKey::NotShowing;
785        };
786        let chosen = match key {
787            Key::Char(c) => splash.entry_for(*c).map(|e| e.action.clone()),
788            _ => None,
789        };
790        self.dismiss_splash();
791        chosen.map_or(SplashKey::Dismissed, SplashKey::Ran)
792    }
793
794    /// The current refresh generation. A renderer caches its products against
795    /// this; equality is the freshness test (an unchanged generation ⇒ the
796    /// last frame is still valid, so skip the re-highlight + re-shape).
797    #[must_use]
798    pub fn edit_gen(&self) -> EditGen {
799        self.edit_gen
800    }
801
802    /// Advance the refresh generation (a mutation happened).
803    fn bump_gen(&mut self) {
804        self.edit_gen = self.edit_gen.next();
805    }
806
807    /// The accumulated dirty region (read-only). See [`take_damage`](Self::take_damage).
808    #[must_use]
809    pub fn damage(&self) -> Damage {
810        self.damage
811    }
812
813    /// Drain the accumulated dirty region, resetting to [`Damage::None`]. The
814    /// renderer calls this once per frame to learn what to repaint, then the
815    /// accumulator restarts — so damage never double-counts across frames.
816    pub fn take_damage(&mut self) -> Damage {
817        std::mem::replace(&mut self.damage, Damage::None)
818    }
819
820    /// The line count of the active buffer (0 if none) — used to compute the
821    /// [`Damage`] scope of a mutation.
822    fn active_line_count(&self) -> u32 {
823        self.buffers
824            .get(self.active)
825            .map_or(0, escriba_buffer::Buffer::line_count)
826    }
827
828    /// Register a lazy USER plugin: its escriba entry is deferred until
829    /// one of its `triggers` fires. Bundled defaults do NOT go through
830    /// here — they are applied eagerly at boot. Empty `triggers` means
831    /// the plugin never lazily activates (the binary applies eager
832    /// plugins directly).
833    pub fn register_lazy_plugin(
834        &mut self,
835        name: impl Into<String>,
836        triggers: Vec<LazyTrigger>,
837        entry_src: impl Into<String>,
838    ) {
839        self.plugin_host.register(name, triggers, entry_src);
840    }
841
842    /// Apply a plugin entry's escriba-lisp to live state — the same
843    /// keymap / command / option apply paths a user rc uses. Options are
844    /// applied before keybinds so a plugin that sets `mapleader` resolves
845    /// `<leader>` correctly. Returns the count of commands + keybinds it
846    /// registered (best-effort; a malformed entry is skipped, not fatal).
847    fn apply_plugin_entry(&mut self, entry_src: &str) -> usize {
848        let Ok(plan) = escriba_lisp::apply_source(entry_src) else {
849            return 0;
850        };
851        let cmd = escriba_lisp::apply_plan_to_commands(&plan, &mut self.commands);
852        escriba_lisp::apply_plan_to_options(&plan, &mut self.options);
853        if let Some(value) = self.options.get("mapleader") {
854            if let Some(key) = escriba_lisp::parse_leader_key(value) {
855                self.keymap.set_leader(key);
856            }
857        }
858        let km = escriba_lisp::apply_plan_to_keymap(&plan, &mut self.keymap);
859        (cmd.registered + km.keybinds_applied) as usize
860    }
861
862    /// Fire any lazy plugin gated on a `FileType` trigger for `filetype`.
863    /// Returns the number of plugins activated. Call when a buffer of a
864    /// known filetype is opened.
865    pub fn activate_filetype_plugins(&mut self, filetype: &str) -> usize {
866        let pending = self.plugin_host.pending_for_filetype(filetype);
867        let n = pending.len();
868        for src in pending {
869            self.apply_plugin_entry(&src);
870        }
871        n
872    }
873
874    /// Fire any lazy plugin gated on an `Event` trigger for `event`.
875    /// Returns the number of plugins activated.
876    pub fn activate_event_plugins(&mut self, event: &str) -> usize {
877        let pending = self.plugin_host.pending_for_event(event);
878        let n = pending.len();
879        for src in pending {
880            self.apply_plugin_entry(&src);
881        }
882        n
883    }
884
885    /// Advance one frame's worth of state given a raw madori event.
886    ///
887    /// Key events pass through the [`KeyRepeatGate`] first (see
888    /// [`Self::tick_at`]); everything else is handled directly.
889    pub fn tick(&mut self, event: &AppEvent) {
890        self.tick_at(event, Instant::now());
891    }
892
893    /// [`Self::tick`] with an explicit timestamp for the key-repeat gate —
894    /// lets tests drive the debounce window without depending on the
895    /// wall clock.
896    pub fn tick_at(&mut self, event: &AppEvent, now: Instant) {
897        match translate_app_event(event) {
898            InputOutcome::Key(k) => {
899                if self.gate_key(&k, now) {
900                    self.on_key(&k);
901                }
902            }
903            InputOutcome::Resized { .. } => {
904                // Damage only. Each face owns its own geometry: the GPU
905                // backend derives the grid in `RenderCallback::resize`, and
906                // the ratatui face reads its area every frame. This arm used
907                // to write `Window.rect`, which nothing ever read — so the
908                // resize path was already doing no real work, it just looked
909                // like it was.
910                self.damage = self.damage.join(Damage::Viewport);
911                self.bump_gen();
912            }
913            InputOutcome::Quit => self.quit_requested = true,
914            InputOutcome::Focus(_) | InputOutcome::None => {}
915        }
916    }
917
918    /// Decide whether `key` survives the key-repeat gate at time `now`.
919    ///
920    /// Returns `true` when the key should be processed, `false` when it is
921    /// an OS key-repeat storm tick that should be dropped. Gating applies
922    /// ONLY in the navigation modes (Normal / Visual / VisualLine) — those
923    /// are where a held `j`/`l` floods the motion path and thrashes the
924    /// viewport. Insert and Command modes pass every key through ungated,
925    /// because there "hold a key to repeat the character" is the intended
926    /// behavior, not a storm to suppress.
927    fn gate_key(&mut self, key: &Key, now: Instant) -> bool {
928        match self.modal.mode() {
929            Mode::Normal | Mode::Visual | Mode::VisualLine => {
930                // The gate exists for HELD keys that flood the motion path and
931                // thrash the viewport (`j`, `l`). It is wrong for the discrete
932                // jumps: two `n` presses 10 ms apart mean two matches, and
933                // swallowing the second is indistinguishable from a dead key —
934                // the exact symptom the gate was added to prevent elsewhere.
935                if is_repeat_storm_candidate(key) {
936                    return self.repeat_gate.try_pass_at(*key, now);
937                }
938                true
939            }
940            Mode::Insert | Mode::Command => true,
941        }
942    }
943
944    /// Dispatch a single key through the keymap + apply the resulting action.
945    pub fn on_key(&mut self, key: &Key) {
946        // The start screen owns the first keypress and nothing after it.
947        match self.consume_splash_key(key) {
948            SplashKey::NotShowing | SplashKey::Dismissed => {}
949            SplashKey::Ran(action) => {
950                self.apply(&action);
951                return;
952            }
953        }
954        // Multi-key sequence resolution runs first: a key that begins or
955        // continues a bound sequence (`<leader>ff`, `gg`) is held or
956        // resolved here before the single-key path sees it.
957        match self.step_sequence(key) {
958            SeqStep::Pending => return,
959            SeqStep::Resolved(action) => {
960                let count = self.modal.pending_count().unwrap_or(1);
961                self.modal.clear_count();
962                for _ in 0..count {
963                    self.apply(&action);
964                    if self.quit_requested {
965                        return;
966                    }
967                }
968                return;
969            }
970            SeqStep::Passthrough => {}
971        }
972        let counted = self.keymap.dispatch(&self.modal, key);
973        // Count prefixes accumulate into modal state.
974        if matches!(counted.action, Action::Pending) {
975            if let Key::Char(c) = key {
976                if c.is_ascii_digit() {
977                    let d = u32::from(*c as u8 - b'0');
978                    self.modal.append_count(d);
979                }
980            }
981            return;
982        }
983        // The count flows through the operator-pending FSM (apply_counted), which
984        // owns repetition: a bare motion runs count× , an operator captures its
985        // count, and an operated motion multiplies the two. No naive outer loop.
986        self.apply_counted(&counted.action, counted.count);
987        // After applying, reset pending count.
988        self.modal.clear_count();
989    }
990
991    /// Advance the multi-key pending-stroke state machine for `key`.
992    ///
993    /// Sequences only apply in normal / visual modes — insert and
994    /// command modes treat keys as literal text. Rules:
995    /// - Mid-sequence: extend the pending prefix. Exact match →
996    ///   [`SeqStep::Resolved`]; still a live prefix → [`SeqStep::Pending`];
997    ///   otherwise abort the sequence and re-process this key fresh.
998    /// - Not mid-sequence: if `key` begins a bound sequence AND is not
999    ///   itself a complete single binding (single bindings win, so no
1000    ///   chord timeout is needed) → start pending. Otherwise
1001    ///   [`SeqStep::Passthrough`] to the single-key dispatcher.
1002    fn step_sequence(&mut self, key: &Key) -> SeqStep {
1003        let mode = self.modal.mode();
1004        if !matches!(mode, Mode::Normal | Mode::Visual | Mode::VisualLine) {
1005            return SeqStep::Passthrough;
1006        }
1007        if !self.pending_keys.is_empty() {
1008            let mut seq = self.pending_keys.clone();
1009            seq.push(key.clone());
1010            if let Some(b) = self.keymap.lookup_sequence(mode, &seq) {
1011                let action = b.action.clone();
1012                self.pending_keys.clear();
1013                return SeqStep::Resolved(action);
1014            }
1015            if self.keymap.is_sequence_prefix(mode, &seq) {
1016                self.pending_keys = seq;
1017                return SeqStep::Pending;
1018            }
1019            // The key broke the in-progress sequence — abort it and let
1020            // the key be re-processed as a fresh stroke below.
1021            self.pending_keys.clear();
1022        }
1023        let start = [key.clone()];
1024        if self.keymap.is_sequence_prefix(mode, &start) && self.keymap.lookup(mode, key).is_none() {
1025            self.pending_keys = start.to_vec();
1026            return SeqStep::Pending;
1027        }
1028        SeqStep::Passthrough
1029    }
1030
1031    /// The primary cursor position. The single read accessor — every
1032    /// renderer + motion path goes through it, so the underlying
1033    /// representation (today a single-cursor [`Cursors`]) can grow to
1034    /// multi-caret without changing read sites.
1035    #[must_use]
1036    pub fn cursor(&self) -> Position {
1037        self.cursors.primary()
1038    }
1039
1040    /// The **single** cursor-mutation path. Clamp the requested position to
1041    /// the active buffer's bounds, then scroll the active window's viewport
1042    /// to contain it on BOTH axes. Routing every cursor change through this
1043    /// (and through [`Cursors::set_primary`]) makes "cursor outside its
1044    /// viewport" an unrepresentable state, AND keeps cursor state in ONE
1045    /// typed home — there is no code path that advances the cursor without
1046    /// re-deriving the viewport from it, and no second `Position` field to
1047    /// fall out of sync.
1048    /// Re-assert the cursor-visibility invariant against the CURRENT
1049    /// viewport.
1050    ///
1051    /// A resize changes how much a face can show without moving the cursor,
1052    /// so nothing would otherwise re-run `scroll_to_contain` — the cursor
1053    /// would sit off-screen until the operator happened to move it. Every
1054    /// face calls this after telling the runtime its new size.
1055    pub fn refollow_cursor(&mut self) {
1056        self.set_cursor(self.cursors.primary());
1057    }
1058
1059    fn set_cursor(&mut self, pos: Position) {
1060        let clamped = if let Some(buf) = self.buffers.get(self.active) {
1061            buf.clamp(pos)
1062        } else {
1063            pos
1064        };
1065        self.cursors.set_primary(clamped);
1066        if let Some(w) = self
1067            .layout
1068            .windows
1069            .iter_mut()
1070            .find(|w| w.id == self.layout.active)
1071        {
1072            w.viewport = w.viewport.scroll_to_contain(self.cursors.primary(), 2);
1073        }
1074    }
1075
1076    /// Dispatch one resolved action at count 1. See [`apply_counted`](Self::apply_counted).
1077    fn apply(&mut self, action: &Action) {
1078        self.apply_counted(action, 1);
1079    }
1080
1081    /// Dispatch one resolved action with its count. Routes `(action, count)`
1082    /// through the operator-pending FSM ([`OperatorPending`], on `zenmai`): most
1083    /// actions pass straight to [`apply_resolved`](Self::apply_resolved) carrying
1084    /// their count (so `5j` runs the motion 5×), an operator key is held, and an
1085    /// operator-then-motion pair is rewritten into a counted
1086    /// [`Action::ApplyOperator`] (so `3dw` deletes 3 words). The FSM owns count
1087    /// composition — there is no naive outer repeat loop.
1088    fn apply_counted(&mut self, action: &Action, count: u32) {
1089        // An uncompilable pattern must not reach the operator machine.
1090        //
1091        // `SearchState::accept` puts the prompt BACK on a compile error so the
1092        // typed text is not lost — but the FSM had already transitioned out of
1093        // `AwaitingSearch` on the way in, so the prompt survived and the
1094        // OPERATOR did not, with nothing said about it. The `d` was simply
1095        // gone, and the corrected pattern then ran as a bare search.
1096        //
1097        // The machine is a pure `(State, Event) -> (State, effects)` and
1098        // cannot observe the result of an effect, so it cannot decide this
1099        // itself. The fix is to stop handing it an event it has no business
1100        // deciding: the runtime classifies the submit first, from state it
1101        // already holds. `prompt_error` returns `None` for an EMPTY prompt, so
1102        // the bare-`/<CR>` reuse path is untouched.
1103        //
1104        // Tier-honest: parse-rejected at the boundary, not
1105        // truly-unrepresentable.
1106        if matches!(action, Action::SubmitCommand) {
1107            if let Some(e) = self.search.prompt_error() {
1108                let mut m = String::from("E383: Invalid search string: ");
1109                m.push_str(&e.to_string());
1110                self.messages.push(m);
1111                return;
1112            }
1113        }
1114
1115        for (resolved, times) in self.op_pending.dispatch((action.clone(), count)) {
1116            for _ in 0..times {
1117                self.apply_resolved(&resolved);
1118                if self.quit_requested {
1119                    return;
1120                }
1121            }
1122        }
1123    }
1124
1125    /// The active buffer's text. Search is a pure function of it.
1126    /// The active buffer's text revision — the token an offset measured
1127    /// against it should carry.
1128    #[must_use]
1129    fn text_rev(&self) -> TextRev {
1130        self.buffers
1131            .get(self.active)
1132            .map_or_else(TextRev::default, escriba_buffer::Buffer::text_rev)
1133    }
1134
1135    fn active_text(&self) -> String {
1136        self.buffers
1137            .get(self.active)
1138            .map(escriba_buffer::Buffer::to_string)
1139            .unwrap_or_default()
1140    }
1141
1142    /// The cursor as a char offset — the coordinate search speaks.
1143    fn cursor_char(&self) -> usize {
1144        self.buffers
1145            .get(self.active)
1146            .and_then(|b| b.position_to_char(self.cursor()).ok())
1147            .unwrap_or(0)
1148    }
1149
1150    /// Move the cursor onto a match and report a wrap the way vim does.
1151    /// The status line as data — what every face draws.
1152    ///
1153    /// One model, so the two faces can only disagree about styling. Before
1154    /// this existed the GPU face built its own line from a fixed `format!()`
1155    /// and drew neither the prompt nor any message, which made a fully
1156    /// working `/` look like a dead key on escriba's default renderer.
1157    #[must_use]
1158    pub fn status_model(&self) -> StatusModel<'_> {
1159        let cursor = self.cursor();
1160        let prompt = self.search.prompt();
1161
1162        let kind = match prompt.map(|p| p.direction) {
1163            Some(escriba_search::Direction::Forward) => PromptKind::SearchForward,
1164            Some(escriba_search::Direction::Backward) => PromptKind::SearchBackward,
1165            // Command mode with no search prompt open is an ex-command; the
1166            // typed `Option<Prompt>` is the discriminator, never a mode flag.
1167            None if self.modal.mode() == Mode::Command => PromptKind::Ex,
1168            None => PromptKind::None,
1169        };
1170
1171        StatusModel {
1172            mode: self.modal.mode(),
1173            line: cursor.line.saturating_add(1) as usize,
1174            column: cursor.column.saturating_add(1) as usize,
1175            prompt: kind,
1176            prompt_text: prompt
1177                .map_or_else(|| self.modal.minibuffer(), escriba_search::Prompt::text),
1178            prompt_caret: prompt.map_or_else(
1179                || self.modal.minibuffer_caret(),
1180                escriba_search::Prompt::caret,
1181            ),
1182            count: self.match_count(),
1183            message: self.messages.last().map(String::as_str),
1184        }
1185    }
1186
1187    /// `[3/17]` for the current pattern.
1188    ///
1189    /// While a prompt is open the count describes the PREVIEW — the answer to
1190    /// "what would Enter do", which is the question being asked mid-typing.
1191    /// Once committed it describes where the cursor actually is.
1192    #[must_use]
1193    fn match_count(&self) -> MatchCount {
1194        if self.search.is_prompting() {
1195            let text = self.active_text();
1196            // ONE scan, four outcomes. `Incomplete` and `NoMatch` used to be
1197            // the same `None`, so a half-typed character class reported
1198            // `[0/0]` — telling the user their pattern matches nothing while
1199            // they are still writing it.
1200            return match self.search.preview(&text) {
1201                escriba_search::Preview::Landed { step, total } => {
1202                    MatchCount::new(step.index, total)
1203                }
1204                escriba_search::Preview::NoMatch => MatchCount::None,
1205                escriba_search::Preview::Incomplete | escriba_search::Preview::Idle => {
1206                    MatchCount::Idle
1207                }
1208            };
1209        }
1210        if self.search.pattern().is_none() {
1211            return MatchCount::Idle;
1212        }
1213        let total = self.search.matches().len();
1214        // Read THROUGH the anchor: an ordinal computed against text that has
1215        // since changed reads as absent, so a stale count cannot be displayed.
1216        let rev = self.text_rev();
1217        self.search_at.as_ref().and_then(|a| a.get(rev)).map_or(
1218            if total == 0 {
1219                MatchCount::None
1220            } else {
1221                MatchCount::Idle
1222            },
1223            |&i| MatchCount::new(i, total),
1224        )
1225    }
1226
1227    /// `.` — replay the last change at the cursor.
1228    ///
1229    /// Two steps, because a change can be two: run the action, then re-type
1230    /// whatever followed it. `cgn` + `.` is exactly this — change the next
1231    /// match, then repeat that whole gesture on the one after.
1232    fn repeat_last_change(&mut self) {
1233        let Some(change) = self.last_change.clone() else {
1234            self.messages
1235                .push("E32: No previous change to repeat".to_string());
1236            return;
1237        };
1238
1239        for _ in 0..change.count.max(1) {
1240            self.apply_resolved(&change.action);
1241        }
1242        for c in change.inserted.chars() {
1243            self.apply_resolved(&Action::InsertChar(c));
1244        }
1245        if self.modal.mode() == Mode::Insert {
1246            // A replayed change must not leave the editor in Insert — the
1247            // original ended with an Esc the recording deliberately does not
1248            // store, since it is punctuation rather than part of the change.
1249            self.apply_resolved(&Action::ChangeMode(Mode::Normal));
1250        }
1251        // The replay wrote through `apply_resolved`, which re-records
1252        // `last_change` from the inner action. Put the ORIGINAL back so a
1253        // second `.` repeats the same change rather than a fragment of it.
1254        self.last_change = Some(change);
1255        self.recording_insert = false;
1256    }
1257
1258    /// Resolve a text object to the range it names.
1259    ///
1260    /// `gn` uses the INCLUSIVE step, so a cursor already sitting inside a
1261    /// match operates on THAT match rather than skipping to the next — which
1262    /// is what makes `cgn` then `.` walk matches one at a time instead of
1263    /// every other one.
1264    fn resolve_object(&self, object: escriba_core::TextObject) -> Option<Range> {
1265        use escriba_core::TextObject as O;
1266        let at = self.cursor_char();
1267        let matches = self.search.matches();
1268
1269        // A match CONTAINING the cursor wins outright, whichever direction the
1270        // object names.
1271        //
1272        // Comparing only against `m.start` — which is what a `starts`-vector
1273        // plus `Bound::Inclusive` does — is right only when the cursor sits on
1274        // a match's FIRST character. One column further in, `start < at` and
1275        // the match is rejected, so `cgn` skipped the very instance the
1276        // operator was standing in and the rename silently missed it. vim
1277        // operates on the containing match from every interior column, and the
1278        // `starts`-only comparison cannot express "contains" because it never
1279        // looks at `m.end`.
1280        let idx = matches.iter().position(|m| m.contains(at)).or_else(|| {
1281            let starts: Vec<usize> = matches.iter().map(|m| m.start).collect();
1282            match object {
1283                O::NextMatch => Bound::Inclusive.first_matching(&starts, at, true),
1284                O::PrevMatch => Bound::Inclusive.first_matching(&starts, at, false),
1285            }
1286        })?;
1287
1288        let m = matches.get(idx)?;
1289        let buf = self.buffers.get(self.active)?;
1290        Some(Range {
1291            start: buf.char_to_position(m.start),
1292            end: buf.char_to_position(m.end),
1293        })
1294    }
1295
1296    fn land_on(&mut self, step: escriba_search::Step) {
1297        if let Some(buf) = self.buffers.get(self.active) {
1298            let pos = buf.char_to_position(step.target.start);
1299            self.set_cursor(pos);
1300        }
1301        // The `[3/17]` numerator. `Step` has carried this index since the
1302        // engine was written — `engine.rs` even names the counter as the
1303        // reason it exists — and every consumer discarded it until now.
1304        self.search_at = Some(Anchored::new(step.index, self.text_rev()));
1305    }
1306
1307    /// vim's "search hit BOTTOM, continuing at TOP".
1308    ///
1309    /// One reporter, called by the two places a search can wrap: the shared
1310    /// commit and `n`/`N`. `land_on` deliberately does NOT report, or the bare
1311    /// commit would say it twice.
1312    fn report_wrap(&mut self, step: &escriba_search::Step) {
1313        if let Some(msg) = escriba_search::wrap_message(step.wrapped) {
1314            self.messages.push(msg.to_string());
1315        }
1316    }
1317
1318    /// `n` / `N`. Reports vim's E486 when the pattern matches nothing, rather
1319    /// than failing silently — a search that appears to do nothing is
1320    /// indistinguishable from a dropped keystroke.
1321    fn jump_search(&mut self, reverse: bool) {
1322        // Using the matches re-lights them: `n` after an auto-clear shows you
1323        // what you are walking through.
1324        self.search.relight();
1325        // `n` is a far jump — record where we leave from so `<C-o>` works.
1326        self.jumps.push(self.cursor());
1327        let at = self.cursor_char();
1328        match self.search.repeat(at, reverse) {
1329            Some(step) => {
1330                // `n` wrapping the file says so, same as a commit does.
1331                self.report_wrap(&step);
1332                self.land_on(step);
1333            }
1334            None => {
1335                let msg = self.search.pattern().map_or_else(
1336                    || "E35: No previous regular expression".to_string(),
1337                    |p| {
1338                        let mut m = String::from("E486: Pattern not found: ");
1339                        m.push_str(p.raw());
1340                        m
1341                    },
1342                );
1343                self.messages.push(msg);
1344            }
1345        }
1346    }
1347
1348    /// Move the cursor to where the in-progress pattern would land, without
1349    /// committing anything. vim's `incsearch`.
1350    ///
1351    /// A pattern that does not compile yet (`/a[`, mid-typing) previews
1352    /// nothing and reports nothing — an error toast on every keystroke of a
1353    /// character class would be unusable.
1354    fn preview_search(&mut self) {
1355        let text = self.active_text();
1356        let Some(origin) = self.search.prompt().map(|p| p.origin) else {
1357            return;
1358        };
1359        let target = match self.search.preview(&text) {
1360            escriba_search::Preview::Landed { step, .. } => step.target.start,
1361            // Nothing to show: back to where the search started. Covers a
1362            // half-typed pattern and a pattern that finds nothing alike —
1363            // both mean "there is no match to preview".
1364            escriba_search::Preview::Idle
1365            | escriba_search::Preview::Incomplete
1366            | escriba_search::Preview::NoMatch => origin,
1367        };
1368        // A pattern that STOPS matching returns the cursor to the origin.
1369        //
1370        // Preview used to only ever move forward, so typing `ch` (a match) and
1371        // then `chz` (none) left the cursor parked on the `ch` match — a
1372        // preview showing a position the pattern no longer justifies, while
1373        // the count beside it read `[0/0]`. Restoring is also what makes
1374        // Escape's promise legible: at every keystroke the cursor is either on
1375        // a real match or back where you started, never on a stale one.
1376        if let Some(buf) = self.buffers.get(self.active) {
1377            let pos = buf.char_to_position(target);
1378            self.set_cursor(pos);
1379        }
1380    }
1381
1382    /// `d/foo<CR>` — commit the prompt and operate from the prompt's origin to
1383    /// where the search lands, as ONE action.
1384    ///
1385    /// Split from [`Self::submit_search`] rather than sharing it because the
1386    /// two want opposite things from the commit: the bare `/` MOVES the cursor
1387    /// to the match, and an operated `/` must NOT — the cursor is the
1388    /// operator's start point, and moving it first would leave the operator
1389    /// with a zero-width range.
1390    /// Commit the open search prompt. The ONE copy of the sequence.
1391    ///
1392    /// Reports its own failures (E486 / E35) so neither caller has to carry a
1393    /// third copy of the message strings. `Accepted::Invalid` cannot reach
1394    /// here — `apply_counted` rejects an uncompilable pattern at the dispatch
1395    /// boundary before the FSM or this method ever sees the submit.
1396    fn commit_search_prompt(&mut self) -> CommitOutcome {
1397        let text = self.active_text();
1398        let Some((origin, skip)) = self.search.prompt().map(|p| (p.origin, p.preview_skip()))
1399        else {
1400            return CommitOutcome::NoPrompt;
1401        };
1402
1403        match self.search.accept(&text) {
1404            escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
1405                self.modal.clear_minibuffer();
1406                self.modal.enter(Mode::Normal);
1407                match self.search.commit_step_skipping(origin, skip) {
1408                    Some(step) => {
1409                        // The wrap notice belongs HERE, once, for both commit
1410                        // paths. Reporting it in each caller is what let the
1411                        // operated path lose it in the first place — and my
1412                        // first attempt at this refactor duplicated it again
1413                        // rather than moving it, which the red proof caught.
1414                        self.report_wrap(&step);
1415                        CommitOutcome::Landed { origin, step }
1416                    }
1417                    None => {
1418                        self.report_pattern_not_found();
1419                        CommitOutcome::NotFound
1420                    }
1421                }
1422            }
1423            escriba_search::Accepted::NothingToRepeat => {
1424                self.modal.clear_minibuffer();
1425                self.modal.enter(Mode::Normal);
1426                self.messages
1427                    .push("E35: No previous regular expression".to_string());
1428                CommitOutcome::NoPrevious
1429            }
1430            // Unreachable: the boundary guard in `apply_counted` returns early
1431            // on an uncompilable pattern, leaving the prompt open. Reported
1432            // rather than `unreachable!()` — a panic in the editor's commit
1433            // path is a worse failure than a duplicate message.
1434            escriba_search::Accepted::Invalid(e) => {
1435                let mut m = String::from("E383: Invalid search string: ");
1436                m.push_str(&e.to_string());
1437                self.messages.push(m);
1438                CommitOutcome::NoPrompt
1439            }
1440        }
1441    }
1442
1443    /// vim's E486, with the pattern named. One place, so every path that fails
1444    /// to find reports identically.
1445    fn report_pattern_not_found(&mut self) {
1446        let mut m = String::from("E486: Pattern not found");
1447        if let Some(p) = self.search.pattern() {
1448            m.push_str(": ");
1449            m.push_str(p.raw());
1450        }
1451        self.messages.push(m);
1452    }
1453
1454    /// Bare `/foo<CR>` — commit and MOVE the cursor to the match.
1455    ///
1456    /// The only difference from the operated path is that this one lands;
1457    /// everything else lives in `commit_search_prompt`.
1458    fn submit_search(&mut self) {
1459        match self.commit_search_prompt() {
1460            CommitOutcome::Landed { origin, step } => {
1461                if let Some(buf) = self.buffers.get(self.active) {
1462                    let from = buf.char_to_position(origin);
1463                    self.jumps.push(from);
1464                }
1465                self.land_on(step);
1466            }
1467            CommitOutcome::NotFound | CommitOutcome::NoPrevious | CommitOutcome::NoPrompt => {}
1468        }
1469    }
1470
1471    /// `d/foo<CR>` — commit, then operate from the prompt's origin to where the
1472    /// search lands, as ONE action.
1473    ///
1474    /// The cursor must NOT move to the match first: it is the operator's start
1475    /// point. That is the whole reason this differs from the bare path, and
1476    /// now the only reason.
1477    fn submit_search_operated(&mut self, op: Operator) {
1478        match self.commit_search_prompt() {
1479            CommitOutcome::Landed { origin, step } => {
1480                if let Some(buf) = self.buffers.get(self.active) {
1481                    let from = buf.char_to_position(origin);
1482                    let target = buf.char_to_position(step.target.start);
1483                    // Operating over a search is itself a far jump.
1484                    self.jumps.push(from);
1485                    self.set_cursor(from);
1486                    self.apply_operator_to(op, target);
1487                }
1488            }
1489            CommitOutcome::NotFound | CommitOutcome::NoPrevious | CommitOutcome::NoPrompt => {}
1490        }
1491    }
1492
1493    fn apply_resolved(&mut self, action: &Action) {
1494        // Snapshot the scope inputs before the mutation so the resulting
1495        // Damage covers the changed region (the S3 seal — conservative widen).
1496        let lines_before = self.active_line_count();
1497        // Snapshot for the dot register: the only reliable witness that this
1498        // action changed text is that the buffer's revision moved.
1499        let rev_before = self.text_rev();
1500        let cline_before = self.cursor().line;
1501        match action {
1502            // Every action with an exact slip equivalent goes through the
1503            // interpreter, so "undo" has ONE implementation rather than one
1504            // per entry point. These had already drifted: the executor
1505            // re-followed the viewport after undo and the M1 interpreter did
1506            // not, so `u` and `:undo` behaved differently within a milestone
1507            // of each other.
1508            // Listed EXPLICITLY rather than behind a `if lower(..).is_some()`
1509            // guard: a guard arm does not count toward exhaustiveness, so the
1510            // guarded form silently gave up the total match — the compiler
1511            // said so, and it was right. `lowering_and_dispatch_agree` pins
1512            // that this list and `lower` stay the same set.
1513            Action::Quit
1514            | Action::ClearSearchHighlight
1515            | Action::Save
1516            | Action::Undo
1517            | Action::Redo
1518            | Action::Edit(_) => {
1519                for slip in Self::lower(action, self.active).unwrap_or_default() {
1520                    self.honour_one(slip);
1521                }
1522            }
1523            Action::Move(m) => self.apply_motion(*m),
1524            Action::SearchOpen(dir) => {
1525                // vim's `/` is the command-line with a different prompt char,
1526                // so we reuse Command mode; `search.prompt` is what tells a
1527                // later <CR> this is a search and not an ex-command.
1528                let origin = self.cursor_char();
1529                self.search.open(*dir, origin);
1530                self.modal.enter(Mode::Command);
1531            }
1532            Action::SearchRepeat { reverse } => self.jump_search(*reverse),
1533            Action::SearchWord { reverse } => {
1534                let dir = if *reverse {
1535                    SearchDirection::Backward
1536                } else {
1537                    SearchDirection::Forward
1538                };
1539                let (text, at) = (self.active_text(), self.cursor_char());
1540                // `*` jumps, so it records too.
1541                self.jumps.push(self.cursor());
1542                match self.search.search_word(&text, at, dir) {
1543                    Some(step) => self.land_on(step),
1544                    // vim beeps and stays put when there is no word under the
1545                    // cursor; a silent no-op would look like a broken key.
1546                    None => self
1547                        .messages
1548                        .push("E348: No string under cursor".to_string()),
1549                }
1550            }
1551            Action::SearchSubmitOperated { op } => self.submit_search_operated(*op),
1552            Action::TextObject(object) => {
1553                // Bare `gn` moves onto the match. vim additionally starts a
1554                // Visual selection of it; escriba's Visual plumbing does not
1555                // carry a selection an operator can consume yet, so this
1556                // stops at the jump rather than faking a selection that
1557                // nothing would honour.
1558                if let Some(range) = self.resolve_object(*object) {
1559                    self.jumps.push(self.cursor());
1560                    self.set_cursor(range.start);
1561                } else {
1562                    self.report_pattern_not_found();
1563                }
1564            }
1565            Action::ApplyOperatorObject { op, object } => match self.resolve_object(*object) {
1566                Some(range) => self.apply_operator_over(*op, range),
1567                None => self.report_pattern_not_found(),
1568            },
1569            Action::RepeatLastChange => self.repeat_last_change(),
1570            Action::JumpBack => {
1571                let here = self.cursor();
1572                if let Some(pos) = self.jumps.back(here) {
1573                    self.set_cursor(pos);
1574                } else {
1575                    self.messages
1576                        .push("E662: At start of changelist".to_string());
1577                }
1578            }
1579            Action::JumpForward => {
1580                if let Some(pos) = self.jumps.forward() {
1581                    self.set_cursor(pos);
1582                } else {
1583                    self.messages.push("E663: At end of changelist".to_string());
1584                }
1585            }
1586            Action::ChangeMode(m) => {
1587                // Leaving the cmdline abandons any open search prompt and
1588                // returns the cursor home. The COMMITTED pattern survives —
1589                // cancelling a new search must not erase the old highlights.
1590                if *m == Mode::Normal && self.search.is_prompting() {
1591                    if let Some(origin) = self.search.cancel() {
1592                        if let Some(buf) = self.buffers.get(self.active) {
1593                            let pos = buf.char_to_position(origin);
1594                            self.set_cursor(pos);
1595                        }
1596                    }
1597                }
1598                self.modal.enter(*m);
1599            }
1600            Action::InsertChar(c) => self.insert_char(*c),
1601
1602            Action::SubmitCommand => {
1603                if self.search.is_prompting() {
1604                    self.submit_search();
1605                } else {
1606                    self.submit_command();
1607                }
1608            }
1609            Action::Command { name, args } => self.run_command(name, args),
1610            Action::ApplyOperator { op, motion } => self.apply_operator(*op, *motion),
1611            // The operator-pending FSM consumes Operator keys (begins pending);
1612            // they never reach the executor. Defensive no-op for exhaustiveness.
1613            Action::Operator(_) => {}
1614            Action::PromptCaret { to } => {
1615                // Both prompts have a caret now, and the same keys move it.
1616                if self.search.is_prompting() {
1617                    self.search.move_caret(*to);
1618                } else {
1619                    self.modal.move_minibuffer_caret(*to);
1620                }
1621            }
1622            Action::SearchPreviewStep { forward } => {
1623                if self.search.is_prompting() {
1624                    self.search.preview_step(*forward);
1625                    self.preview_search();
1626                }
1627            }
1628            Action::PromptDelete => {
1629                if self.search.is_prompting() {
1630                    self.search.delete_at_caret();
1631                    self.preview_search();
1632                } else {
1633                    self.modal.delete_minibuffer_at_caret();
1634                }
1635            }
1636            Action::PromptDeleteWord => {
1637                if self.search.is_prompting() {
1638                    self.search.delete_word_before_caret();
1639                    self.preview_search();
1640                }
1641            }
1642            Action::PromptClearToStart => {
1643                if self.search.is_prompting() {
1644                    self.search.clear_before_caret();
1645                    self.preview_search();
1646                }
1647            }
1648            Action::PromptBackspace => {
1649                self.prompt_backspace();
1650                // Shortening the pattern changes which matches exist, so the
1651                // preview must re-run — otherwise the cursor sits on a match
1652                // of a pattern that is no longer typed.
1653                if self.search.is_prompting() {
1654                    self.preview_search();
1655                }
1656            }
1657            Action::PromptHistory { back } => {
1658                if self.search.is_prompting() {
1659                    self.search.history_step(*back);
1660                    // No minibuffer resync: the shadow is the ex-line's store
1661                    // and nothing reads it while a search prompt is open, so
1662                    // rewriting it here was maintaining a copy for no reader.
1663                    self.preview_search();
1664                }
1665            }
1666            Action::Pending => {}
1667        }
1668        // Widen the dirty region by what this action touched (M1). Content
1669        // mutations that changed the line count run to end-of-document (every
1670        // line below shifted); an in-place edit or a cursor move is local;
1671        // arbitrary commands are conservatively Full. Never narrows.
1672        let lines_after = self.active_line_count();
1673        let cline_after = self.cursor().line;
1674        let d = match action {
1675            // A search repaints every highlight in the viewport, not just the
1676            // line the cursor left — so it must widen to Full. Treating it as a
1677            // cursor move would leave stale highlights on untouched lines.
1678            Action::SearchOpen(_)
1679            | Action::PromptHistory { .. }
1680            | Action::PromptBackspace
1681            | Action::PromptCaret { .. }
1682            | Action::SearchPreviewStep { .. }
1683            | Action::PromptDelete
1684            | Action::PromptDeleteWord
1685            | Action::PromptClearToStart
1686            | Action::SearchRepeat { .. }
1687            | Action::SearchWord { .. }
1688            | Action::ClearSearchHighlight
1689            | Action::SearchSubmitOperated { .. }
1690            // A replayed change can edit anywhere the original could, and a
1691            // match object can be anywhere in the document.
1692            | Action::RepeatLastChange
1693            | Action::TextObject(_)
1694            | Action::ApplyOperatorObject { .. }
1695            // A jump can land anywhere, so the viewport may scroll wholesale.
1696            | Action::JumpBack
1697            | Action::JumpForward => Damage::Full,
1698            Action::InsertChar(_)
1699            | Action::Edit(_)
1700            | Action::Undo
1701            | Action::Redo
1702            | Action::ApplyOperator { .. } => {
1703                if lines_after == lines_before {
1704                    Damage::span(cline_before, cline_after)
1705                } else {
1706                    Damage::Lines {
1707                        from: cline_before.min(cline_after),
1708                        to: u32::MAX,
1709                    }
1710                }
1711            }
1712            Action::Move(_) | Action::ChangeMode(_) => Damage::span(cline_before, cline_after),
1713            Action::Save => Damage::Viewport,
1714            Action::Command { .. } | Action::SubmitCommand => Damage::Full,
1715            Action::Quit | Action::Operator(_) | Action::Pending => Damage::None,
1716        };
1717        self.damage = self.damage.join(d);
1718        // Remember this change for `.`.
1719        //
1720        // Recorded from an OBSERVED MUTATION, not from the action's variant.
1721        // `text_effect()` is the wrong predicate here even though it looks
1722        // like the right one: it exists to decide cache invalidation, where
1723        // OVER-reporting is the safe direction, and the dot register needs the
1724        // opposite bias. Leaning on it meant `last_change` was set by actions
1725        // that changed no text at all, with two measured consequences:
1726        //
1727        //   `iZ<Esc>` then `/a<CR>` then `.`  — did nothing; the register held
1728        //       `SubmitCommand`, whose replay reads an already-cleared
1729        //       minibuffer.
1730        //   `iZ<Esc>` then `/q<Esc>` then `.` — TYPED `q` INTO THE BUFFER. An
1731        //       abandoned prompt left the register holding `InsertChar('q')`,
1732        //       and `.` in Normal mode routes that to the text. A corrupting
1733        //       register, not merely a lost one.
1734        //
1735        // Comparing the buffer's `TextRev` across the action answers the only
1736        // question that matters — did this actually change the text — and gets
1737        // the failed-operator case (`dgn` with no pattern) right for free.
1738        if self.recording_insert {
1739            match action {
1740                Action::InsertChar(c) => {
1741                    if let Some(lc) = self.last_change.as_mut() {
1742                        lc.inserted.push(*c);
1743                    }
1744                }
1745                // Leaving Insert ends the session; the change is now whole.
1746                Action::ChangeMode(m) if *m != Mode::Insert => self.recording_insert = false,
1747                _ => {}
1748            }
1749        } else if self.text_rev() != rev_before
1750            && !matches!(
1751                action,
1752                Action::RepeatLastChange | Action::Undo | Action::Redo
1753            )
1754        {
1755            self.last_change = Some(LastChange {
1756                action: action.clone(),
1757                count: 1,
1758                inserted: String::new(),
1759            });
1760            self.recording_insert = self.modal.mode() == Mode::Insert;
1761        }
1762
1763        // The search is over the moment you move on or edit — clear the
1764        // highlight rather than leaving the buffer as confetti until an
1765        // explicit `:noh`, which is the remap nearly every vimrc carries.
1766        // Clearing suppresses without forgetting, so `n` still works.
1767        if action.highlight_effect() == HighlightEffect::Clear {
1768            self.search.clear_highlight();
1769        }
1770        // Text changed ⇒ every match offset cached against the old text is
1771        // wrong. `SearchState::refresh` existed for exactly this and had ZERO
1772        // callers, so inserting four characters left both renderers painting
1773        // the highlight four columns off.
1774        //
1775        // Gated on the typed classifier rather than on `bump_gen` (which fires
1776        // for pure cursor moves too): re-scanning the document on every `j`
1777        // would be a per-keystroke full pass for no reason.
1778        if action.text_effect() == TextEffect::Mutates && self.search.pattern().is_some() {
1779            let text = self.active_text();
1780            self.search.refresh(&text);
1781            // NO manual invalidation of `search_at` here, deliberately. It is
1782            // `Anchored` to the text revision, so an ordinal computed against
1783            // the old text now reads as `None` on its own. This is the line
1784            // that used to have to be remembered.
1785        }
1786        // An action reached the executor ⇒ visible state may have changed.
1787        // Advance the refresh generation so the renderer repaints (and
1788        // re-highlights) exactly once. A gated-out key never reaches here, so
1789        // a key-repeat storm does not spin the renderer.
1790        self.bump_gen();
1791    }
1792
1793    /// Resolve a [`Motion`] from `from` to its target [`Position`] against the
1794    /// active buffer — **pure**: no cursor mutation, no side effects. This is
1795    /// the single motion-resolution source of truth that both [`apply_motion`]
1796    /// (move the cursor *to* the target) and [`apply_operator`] (use the target
1797    /// as the *other end* of an operated range) stand on. `None` only if there
1798    /// is no active buffer.
1799    ///
1800    /// [`apply_motion`]: Self::apply_motion
1801    /// [`apply_operator`]: Self::apply_operator
1802    fn resolve_motion(&self, from: Position, motion: Motion) -> Option<Position> {
1803        let buf = self.buffers.get(self.active)?;
1804        let pos = from;
1805        Some(match motion {
1806            // Search-as-motion: what makes `dn` / `d/foo<CR>` work. Resolved
1807            // against the committed match list, so it is `None` (motion fails,
1808            // operator aborts, buffer untouched) when nothing is committed —
1809            // never a silent move to 0, which would delete to the file start.
1810            Motion::SearchNext | Motion::SearchPrev => {
1811                let at = buf.position_to_char(pos).ok()?;
1812                let step = self
1813                    .search
1814                    .repeat(at, matches!(motion, Motion::SearchPrev))?;
1815                buf.char_to_position(step.target.start)
1816            }
1817            Motion::Left => Position::new(pos.line, pos.column.saturating_sub(1)),
1818            Motion::Right => Position::new(pos.line, pos.column.saturating_add(1)),
1819            Motion::Up => Position::new(pos.line.saturating_sub(1), pos.column),
1820            Motion::Down => Position::new(pos.line.saturating_add(1), pos.column),
1821            Motion::LineStart => Position::new(pos.line, 0),
1822            Motion::LineEnd => Position::new(pos.line, buf.line_len_chars(pos.line)),
1823            Motion::LineFirstNonBlank => first_non_blank(buf, pos.line),
1824            Motion::DocStart => Position::ZERO,
1825            Motion::DocEnd => Position::new(
1826                buf.line_count().saturating_sub(1),
1827                buf.line_len_chars(buf.line_count().saturating_sub(1)),
1828            ),
1829            Motion::WordStartNext | Motion::WordEndNext => word_next(buf, pos),
1830            Motion::WordStartPrev => word_prev(buf, pos),
1831            Motion::PageDown | Motion::HalfPageDown => {
1832                Position::new(pos.line.saturating_add(10), pos.column)
1833            }
1834            Motion::PageUp | Motion::HalfPageUp => {
1835                Position::new(pos.line.saturating_sub(10), pos.column)
1836            }
1837            Motion::GotoLine(n) => Position::new(n.saturating_sub(1), 0),
1838            // Structural Lisp motions — stubs for phase 1.B; full paredit
1839            // semantics land when caixa-ast is wired to the active buffer.
1840            Motion::ForwardSexp
1841            | Motion::BackwardSexp
1842            | Motion::UpList
1843            | Motion::DownList
1844            | Motion::BeginningOfDefun
1845            | Motion::EndOfDefun
1846            | Motion::BeginningOfSexp
1847            | Motion::EndOfSexp => pos,
1848        })
1849    }
1850
1851    fn apply_motion(&mut self, motion: Motion) {
1852        // A bare search motion is a FAR JUMP and it REPORTS — it records into
1853        // the jumplist, prints vim's "hit BOTTOM" on a wrap, and says E486
1854        // when nothing matches. `resolve_motion` can do none of that: it is
1855        // deliberately pure because the OPERATOR path calls it to find a range
1856        // without moving the cursor. So `n` routes to the one executor that
1857        // owns those side effects, and `Action::SearchRepeat` routes to the
1858        // same place — one code path, two spellings.
1859        if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
1860            self.jump_search(matches!(motion, Motion::SearchPrev));
1861            return;
1862        }
1863        let Some(pos) = self.resolve_motion(self.cursor(), motion) else {
1864            return;
1865        };
1866        // The single cursor-mutation path clamps to the buffer and scrolls
1867        // the viewport to contain the cursor on both axes.
1868        self.set_cursor(pos);
1869    }
1870
1871    /// Apply an operator over a motion — the vim `{operator}{motion}` verbs
1872    /// (`dw` delete-word, `c$` change-to-line-end, `y0` yank-to-line-start).
1873    /// Composition is explicit: the motion resolves a target via
1874    /// [`resolve_motion`](Self::resolve_motion); the operator acts over the
1875    /// `[cursor, target)` range. Register-leaving operators
1876    /// ([`Operator::leaves_register`]) capture the text first.
1877    fn apply_operator(&mut self, op: Operator, motion: Motion) {
1878        let from = self.cursor();
1879        let Some(to) = self.resolve_motion(from, motion) else {
1880            // A motion that cannot resolve aborts the operator with the buffer
1881            // untouched. A search motion says WHY — `dn` with no pattern armed
1882            // is otherwise indistinguishable from a dropped keystroke, which
1883            // is the same complaint that motivated E486 on the bare path.
1884            if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
1885                if self.search.pattern().is_none() {
1886                    self.messages
1887                        .push("E35: No previous regular expression".to_string());
1888                } else {
1889                    self.report_pattern_not_found();
1890                }
1891            }
1892            return;
1893        };
1894        self.apply_operator_to(op, to);
1895    }
1896
1897    /// Apply `op` over `[cursor, to)`.
1898    ///
1899    /// Split out of [`Self::apply_operator`] so the operated-search path can
1900    /// reach the same range machinery with a target it resolved itself — the
1901    /// alternative was a second copy of the delete/yank/register logic, which
1902    /// is how the two would drift.
1903    fn apply_operator_to(&mut self, op: Operator, to: Position) {
1904        let from = self.cursor();
1905        self.apply_operator_over(
1906            op,
1907            Range {
1908                start: from,
1909                end: to,
1910            },
1911        );
1912    }
1913
1914    /// Apply `op` over an explicit range.
1915    ///
1916    /// The object path needs this: `gn`'s extent need not begin at the cursor,
1917    /// so it cannot go through the `[cursor, target)` shape the motion path
1918    /// uses. One implementation of the delete/yank/register logic, reached two
1919    /// ways.
1920    fn apply_operator_over(&mut self, op: Operator, range: Range) {
1921        let range = range.normalized();
1922        if range.is_empty() {
1923            return;
1924        }
1925        // Capture the operated text (for the register) before mutating.
1926        let text = self
1927            .buffers
1928            .get(self.active)
1929            .and_then(|buf| buf.slice(range).ok());
1930        if op.leaves_register() {
1931            if let Some(t) = &text {
1932                self.register = Some(t.clone());
1933            }
1934        }
1935        match op {
1936            // Delete + Change remove the range; Change then enters Insert so
1937            // the operator pairs with immediate typing (`ciw`, `c$`).
1938            Operator::Delete | Operator::Change => {
1939                if let Some(buf) = self.buffers.get_mut(self.active) {
1940                    let _ = buf.apply(&Edit::delete(range));
1941                }
1942                self.set_cursor(range.start);
1943                if op == Operator::Change {
1944                    self.modal.enter(Mode::Insert);
1945                }
1946            }
1947            // Yank copies to the register without mutating the buffer; vim
1948            // leaves the cursor at the range start.
1949            Operator::Yank => {
1950                self.set_cursor(range.start);
1951            }
1952            // Indent/Format/structural operators are not yet wired — named,
1953            // not faked (no buffer mutation, register already captured for the
1954            // register-leaving ones above).
1955            _ => {
1956                self.messages
1957                    .push("operator not yet implemented".to_owned());
1958            }
1959        }
1960    }
1961
1962    /// The text last yanked or deleted into the unnamed register, if any.
1963    /// The future `p`/`P` paste reads this.
1964    #[must_use]
1965    pub fn register(&self) -> Option<&str> {
1966        self.register.as_deref()
1967    }
1968
1969    fn insert_char(&mut self, c: char) {
1970        if self.modal.mode() == Mode::Command {
1971            // A search prompt and an ex-command share Command mode (vim's
1972            // cmdline). `search.is_prompting()` is the typed discriminator —
1973            // it can only be true when `/` or `?` actually opened a prompt.
1974            if self.search.is_prompting() {
1975                // The search prompt is the SOLE store while it is open.
1976                //
1977                // This used to also `push_minibuffer(c)`, and the two stores
1978                // insert differently — `search.push` at the caret, the
1979                // minibuffer always at the end — so `/fo<Left>X` left them
1980                // reading `fXo` and `foX`. That was one of FIVE desync paths;
1981                // the caret moves, forward-delete, delete-word and
1982                // clear-to-start never touched the shadow at all.
1983                //
1984                // Deleting the write costs nothing because `status_model`
1985                // already selects the minibuffer only on the `prompt == None`
1986                // branch — the shadow is the EX-LINE's store, and while a
1987                // search prompt is open nothing reads it.
1988                self.search.push(c);
1989                self.preview_search();
1990            } else {
1991                self.modal.push_minibuffer(c);
1992            }
1993            return;
1994        }
1995        let cursor = self.cursor();
1996        let Some(buf) = self.buffers.get_mut(self.active) else {
1997            return;
1998        };
1999        let edit = Edit::insert(cursor, c.to_string());
2000        if buf.apply(&edit).is_ok() {
2001            let next = if c == '\n' {
2002                Position::new(cursor.line.saturating_add(1), 0)
2003            } else {
2004                cursor.shift_right(1)
2005            };
2006            // Route through the single cursor-mutation path so the viewport
2007            // follows the cursor (both axes) and the cursor stays clamped.
2008            self.set_cursor(next);
2009        }
2010    }
2011
2012    /// Backspace inside a prompt. Keeps the search buffer and the displayed
2013    /// minibuffer in lockstep — if only one shrank, the pattern submitted
2014    /// would differ from the text on screen.
2015    fn prompt_backspace(&mut self) -> bool {
2016        if self.modal.mode() != Mode::Command {
2017            return false;
2018        }
2019        if self.search.is_prompting() {
2020            // Backspacing past the `/` closes the prompt, as vim does. No
2021            // `pop_minibuffer` here for the same reason as `insert_char`: the
2022            // shadow is the ex-line's, and popping its TAIL when the caret is
2023            // mid-pattern was another desync path.
2024            if self.search.backspace() {
2025                self.modal.clear_minibuffer();
2026                self.modal.enter(Mode::Normal);
2027            }
2028            // Never `pop_minibuffer` on the search path: it pops the TAIL,
2029            // while `search.backspace()` removes the char before the CARET.
2030            return true;
2031        }
2032        self.modal.pop_minibuffer();
2033        true
2034    }
2035
2036    fn submit_command(&mut self) {
2037        // Read the command line BEFORE leaving Command mode — the minibuffer
2038        // exists only in the `Command` variant, so the escape must come
2039        // after the capture.
2040        let line = self.modal.minibuffer().to_string();
2041        self.modal.escape();
2042        let (name, args) = parse_command_line(&line);
2043        if name.is_empty() {
2044            return;
2045        }
2046        self.run_command(&name, &args);
2047    }
2048
2049    fn run_command(&mut self, name: &str, args: &[String]) {
2050        // Bound the command -> RunCommand slip -> command cycle. Refused and
2051        // reported, never a stack overflow: an editor that dies under the
2052        // operator loses their buffer, and a script that loops is a mistake
2053        // they should be told about, not punished for.
2054        if self.dispatch_depth >= Self::MAX_DISPATCH_DEPTH {
2055            let mut m = String::from("command recursion too deep at `");
2056            m.push_str(name);
2057            m.push_str("` — refusing");
2058            self.messages.push(m);
2059            self.damage = self.damage.join(Damage::Viewport);
2060            self.bump_gen();
2061            return;
2062        }
2063        self.dispatch_depth += 1;
2064        self.run_command_inner(name, args);
2065        self.dispatch_depth -= 1;
2066    }
2067
2068    /// How many nested command dispatches are allowed. Deep enough that no
2069    /// legitimate script notices, shallow enough to fail fast.
2070    const MAX_DISPATCH_DEPTH: u8 = 8;
2071
2072    fn run_command_inner(&mut self, name: &str, args: &[String]) {
2073        // Lazy-activation seam (lazy.nvim `cmd =` model): a user plugin
2074        // gated on `Command: <name>` has its entry applied the first time
2075        // that command runs, BEFORE dispatch — so the activated plugin
2076        // can register the very command being invoked and it resolves on
2077        // this same call.
2078        if self.plugin_host.pending() > 0 {
2079            let pending = self.plugin_host.pending_for_command(name);
2080            for src in pending {
2081                self.apply_plugin_entry(&src);
2082            }
2083        }
2084        // Read through the counter, then interpret. Two immutable borrows of
2085        // `self` (the window and the registry) coexist; the `&mut` comes
2086        // afterwards, once the outcome is owned. That sequencing IS the
2087        // seam: there is no moment where a command body and `&mut self` are
2088        // live at the same time.
2089        let outcome = {
2090            let window = self.window();
2091            self.commands.run(name, &window, args)
2092        };
2093        match outcome {
2094            Ok(o) => self.interpret(o),
2095            // Reported, never fatal (Phase 0). A failed command must not
2096            // take the editor down, but it must not be invisible either.
2097            Err(e) => {
2098                self.messages.push(describe_command_failure(name, &e));
2099                self.damage = self.damage.join(Damage::Viewport);
2100                self.bump_gen();
2101            }
2102        }
2103    }
2104
2105    // ── tatara-lisp runtime bridge (imperative programmability tier) ──
2106
2107    /// Capture a read snapshot of the editor for the tatara-lisp host.
2108    /// Lisp reads (`cursor-line`, `current-line`, …) answer from this.
2109    #[must_use]
2110    pub fn snapshot(&self) -> EditorSnapshot {
2111        let current_line = self
2112            .buffers
2113            .get(self.active)
2114            .and_then(|b| b.line(self.cursor().line))
2115            .map(|s| s.trim_end_matches('\n').to_string())
2116            .unwrap_or_default();
2117        let buffer_name = self
2118            .buffers
2119            .get(self.active)
2120            .and_then(|b| b.path.as_ref())
2121            .map(|p| p.display().to_string())
2122            .unwrap_or_else(|| "[scratch]".to_string());
2123        EditorSnapshot {
2124            cursor_line: i64::from(self.cursor().line),
2125            cursor_column: i64::from(self.cursor().column),
2126            current_line,
2127            mode: self.modal.mode().as_str().to_string(),
2128            buffer_name,
2129        }
2130    }
2131
2132    /// Evaluate tatara-lisp `src` against this editor: capture a
2133    /// snapshot, run it in the embedded VM, then apply the typed effects
2134    /// the program emitted. This is the imperative programmability tier
2135    /// — live Lisp that reads state and drives the editor through the
2136    /// sandboxed effect boundary.
2137    ///
2138    /// **Snapshot semantics:** the read snapshot is captured ONCE before
2139    /// eval, and effects are applied AFTER the program returns. So within
2140    /// a single `run_lisp` call a program cannot observe its own writes —
2141    /// `(insert "x") (cursor-column)` reads the pre-insert column. This
2142    /// snapshot-isolation is deliberate (it's what makes the effect
2143    /// boundary a clean sandbox seam); a program that must read its own
2144    /// effects splits the work across calls. The VM is cached
2145    /// ([`Self::lisp_vm`]) so the stdlib is installed once and top-level
2146    /// `define`s persist across calls (REPL-like).
2147    pub fn run_lisp(&mut self, src: &str) -> Result<(), VmError> {
2148        let mut host = EscribaHost::with_snapshot(self.snapshot());
2149        let vm = self.lisp_vm.get_or_insert_with(EscribaVm::new);
2150        vm.eval(src, &mut host)?;
2151        let effects = host.take_effects();
2152        self.apply_host_effects(effects);
2153        Ok(())
2154    }
2155
2156    /// Apply tatara-lisp effects to live editor state.
2157    ///
2158    /// A thin adapter now. It used to be `apply_host_effects`, a THIRD
2159    /// implementation of message-push / option-insert / insert-text beside
2160    /// the Action executor and the slip interpreter — the same duplication
2161    /// that let `u` and `:undo` drift apart in M3. The VM emits slips; this
2162    /// hands them to the one interpreter.
2163    pub fn apply_host_effects(&mut self, effects: Vec<Negai>) {
2164        self.interpret(Outcome::did(effects));
2165    }
2166
2167    /// Insert a (possibly multi-line) string at the cursor and advance
2168    /// the cursor past it. Used by the `(insert …)` effect.
2169    fn insert_text(&mut self, text: &str) {
2170        if text.is_empty() {
2171            return;
2172        }
2173        let cursor = self.cursor();
2174        let Some(buf) = self.buffers.get_mut(self.active) else {
2175            return;
2176        };
2177        let edit = Edit::insert(cursor, text.to_string());
2178        if buf.apply(&edit).is_ok() {
2179            let next = if let Some(nl) = text.rfind('\n') {
2180                let added_lines = u32::try_from(text.matches('\n').count()).unwrap_or(0);
2181                let last_line_len = u32::try_from(text[nl + 1..].chars().count()).unwrap_or(0);
2182                Position::new(cursor.line + added_lines, last_line_len)
2183            } else {
2184                let n = u32::try_from(text.chars().count()).unwrap_or(0);
2185                cursor.shift_right(n)
2186            };
2187            // Route through the single cursor-mutation path so the viewport
2188            // follows the cursor (both axes) and the cursor stays clamped.
2189            self.set_cursor(next);
2190        }
2191    }
2192}
2193
2194fn first_non_blank(buf: &escriba_buffer::Buffer, line: u32) -> Position {
2195    let Some(text) = buf.line(line) else {
2196        return Position::new(line, 0);
2197    };
2198    let col = text
2199        .chars()
2200        .take_while(|c| c.is_whitespace() && *c != '\n')
2201        .count();
2202    Position::new(line, u32::try_from(col).unwrap_or(0))
2203}
2204
2205fn word_next(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
2206    let Some(text) = buf.line(pos.line) else {
2207        return pos;
2208    };
2209    let chars: Vec<char> = text.chars().collect();
2210    let start = pos.column as usize;
2211    let mut i = start;
2212    while i < chars.len() && !chars[i].is_whitespace() {
2213        i += 1;
2214    }
2215    while i < chars.len() && chars[i].is_whitespace() {
2216        i += 1;
2217    }
2218    if i >= chars.len() {
2219        // No more words on this line — jump to next line.
2220        if pos.line + 1 < buf.line_count() {
2221            return Position::new(pos.line + 1, 0);
2222        }
2223    }
2224    Position::new(pos.line, u32::try_from(i).unwrap_or(pos.column))
2225}
2226
2227fn word_prev(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
2228    let Some(text) = buf.line(pos.line) else {
2229        return pos;
2230    };
2231    let chars: Vec<char> = text.chars().collect();
2232    let mut i = (pos.column as usize).min(chars.len());
2233    while i > 0 && chars[i - 1].is_whitespace() {
2234        i -= 1;
2235    }
2236    while i > 0 && !chars[i - 1].is_whitespace() {
2237        i -= 1;
2238    }
2239    Position::new(pos.line, u32::try_from(i).unwrap_or(0))
2240}
2241
2242fn parse_command_line(line: &str) -> (String, Vec<String>) {
2243    let mut parts = line.split_whitespace();
2244    let Some(first) = parts.next() else {
2245        return (String::new(), Vec::new());
2246    };
2247    let head = first.strip_prefix(':').unwrap_or(first);
2248    let name = match head {
2249        "w" => "save",
2250        "q" => "quit",
2251        "u" => "undo",
2252        other => other,
2253    };
2254    (name.to_string(), parts.map(str::to_string).collect())
2255}
2256
2257#[cfg(test)]
2258mod tests {
2259    use super::*;
2260    use madori::event::{KeyCode, KeyEvent, Modifiers};
2261
2262    // ── search wiring (escriba-search integration) ────────────────────
2263    //
2264    // The engine is proven in escriba-search's own 61 tests. These prove the
2265    // WIRING: that keys reach it, that the cursor lands where it says, and
2266    // that a search prompt and an ex-command can share Command mode without
2267    // being confused for one another.
2268
2269    fn type_search(st: &mut EditorState, dir: SearchDirection, pat: &str) {
2270        st.apply(&Action::SearchOpen(dir));
2271        for c in pat.chars() {
2272            st.apply(&Action::InsertChar(c));
2273        }
2274        st.apply(&Action::SubmitCommand);
2275    }
2276
2277    #[test]
2278    fn slash_search_moves_the_cursor_to_the_match() {
2279        let mut st = new_state_with("alpha\nbravo\ncharlie\n");
2280        type_search(&mut st, SearchDirection::Forward, "charlie");
2281        assert_eq!(st.cursor().line, 2, "cursor lands on the matching line");
2282        assert_eq!(st.modal.mode(), Mode::Normal, "prompt closes on submit");
2283        assert_eq!(st.search.matches().len(), 1);
2284    }
2285
2286    #[test]
2287    // `N` is a DIFFERENT vim key from `n` — see escriba-search.
2288    #[allow(non_snake_case)]
2289    fn n_and_N_walk_matches_in_both_directions() {
2290        let mut st = new_state_with("foo\nbar\nfoo\nbaz\nfoo\n");
2291        type_search(&mut st, SearchDirection::Forward, "foo");
2292        let first = st.cursor().line;
2293        st.apply(&Action::SearchRepeat { reverse: false });
2294        let second = st.cursor().line;
2295        assert!(second > first, "n advances ({first} -> {second})");
2296        st.apply(&Action::SearchRepeat { reverse: true });
2297        assert_eq!(st.cursor().line, first, "N comes back");
2298    }
2299
2300    #[test]
2301    fn star_searches_the_word_under_the_cursor() {
2302        let mut st = new_state_with("needle\nhaystack\nneedle\n");
2303        st.apply(&Action::SearchWord { reverse: false });
2304        assert_eq!(st.search.pattern().unwrap().raw(), r"\bneedle\b");
2305        assert_eq!(st.cursor().line, 2, "jumps to the other occurrence");
2306    }
2307
2308    #[test]
2309    fn escape_abandons_the_prompt_and_keeps_the_previous_search() {
2310        let mut st = new_state_with("foo\nbar\nfoo\n");
2311        type_search(&mut st, SearchDirection::Forward, "foo");
2312        let matches_before = st.search.matches().len();
2313
2314        st.apply(&Action::SearchOpen(SearchDirection::Forward));
2315        st.apply(&Action::InsertChar('z'));
2316        st.apply(&Action::ChangeMode(Mode::Normal));
2317
2318        assert!(!st.search.is_prompting(), "prompt gone");
2319        assert_eq!(
2320            st.search.pattern().unwrap().raw(),
2321            "foo",
2322            "old pattern survives"
2323        );
2324        assert_eq!(
2325            st.search.matches().len(),
2326            matches_before,
2327            "old highlights survive"
2328        );
2329    }
2330
2331    #[test]
2332    fn a_search_prompt_and_an_ex_command_are_not_confused() {
2333        let mut st = new_state_with("foo\n");
2334        // No `/` pressed: Command mode belongs to the ex-command line.
2335        st.apply(&Action::ChangeMode(Mode::Command));
2336        assert!(!st.search.is_prompting(), "`:` must not open a search");
2337        st.apply(&Action::InsertChar('w'));
2338        assert!(
2339            st.search.prompt().is_none(),
2340            "typed char went to the ex line"
2341        );
2342    }
2343
2344    #[test]
2345    fn a_missing_pattern_reports_instead_of_failing_silently() {
2346        let mut st = new_state_with("alpha\nbravo\n");
2347        type_search(&mut st, SearchDirection::Forward, "zzz");
2348        assert!(
2349            st.messages.iter().any(|m| m.contains("E486")),
2350            "must report not-found, got {:?}",
2351            st.messages
2352        );
2353    }
2354
2355    #[test]
2356    fn n_without_any_search_reports_rather_than_moving() {
2357        let mut st = new_state_with("alpha\nbravo\n");
2358        let before = st.cursor();
2359        st.apply(&Action::SearchRepeat { reverse: false });
2360        assert_eq!(st.cursor(), before, "cursor must not move");
2361        assert!(
2362            st.messages.iter().any(|m| m.contains("E35")),
2363            "got {:?}",
2364            st.messages
2365        );
2366    }
2367
2368    #[test]
2369    fn search_as_a_motion_composes_with_an_operator() {
2370        // The point of Motion::SearchNext: `d` + search deletes to the match.
2371        let mut st = new_state_with("alpha bravo charlie\n");
2372        type_search(&mut st, SearchDirection::Forward, "charlie");
2373        st.set_cursor(Position::new(0, 0));
2374        let target = st.resolve_motion(Position::new(0, 0), Motion::SearchNext);
2375        assert!(target.is_some(), "search must resolve as a motion");
2376        assert_eq!(target.unwrap().column, 12, "at `charlie`");
2377    }
2378
2379    #[test]
2380    fn search_motion_without_a_pattern_fails_the_motion_instead_of_moving_to_zero() {
2381        // A silent fallback to offset 0 would make `d` + search delete to the
2382        // start of the file — the worst possible failure for an operator.
2383        let st = new_state_with("alpha bravo\n");
2384        assert!(
2385            st.resolve_motion(Position::new(0, 5), Motion::SearchNext)
2386                .is_none()
2387        );
2388    }
2389
2390    #[test]
2391    fn clear_highlight_keeps_the_pattern_usable() {
2392        let mut st = new_state_with("foo\nbar\nfoo\n");
2393        type_search(&mut st, SearchDirection::Forward, "foo");
2394        st.apply(&Action::ClearSearchHighlight);
2395        assert!(st.search.highlights().is_empty(), "nothing lit");
2396        st.apply(&Action::SearchRepeat { reverse: false });
2397        assert!(st.search.pattern().is_some(), "but n still works");
2398    }
2399
2400    #[test]
2401    fn typing_previews_incrementally_before_commit() {
2402        let mut st = new_state_with("alpha\nbravo\ncharlie\n");
2403        st.apply(&Action::SearchOpen(SearchDirection::Forward));
2404        for c in "charlie".chars() {
2405            st.apply(&Action::InsertChar(c));
2406        }
2407        // incsearch: the cursor has already moved, with nothing committed.
2408        assert_eq!(st.cursor().line, 2, "preview moved the cursor");
2409        assert!(st.search.pattern().is_none(), "but nothing is committed");
2410    }
2411
2412    #[test]
2413    fn backspace_corrects_the_prompt_and_reruns_the_preview() {
2414        let mut st = new_state_with("alpha\nbravo\n");
2415        st.apply(&Action::SearchOpen(SearchDirection::Forward));
2416        for c in "bravox".chars() {
2417            st.apply(&Action::InsertChar(c));
2418        }
2419        assert_eq!(st.search.prompt().unwrap().text(), "bravox");
2420        st.apply(&Action::PromptBackspace);
2421        assert_eq!(
2422            st.search.prompt().unwrap().text(),
2423            "bravo",
2424            "typo corrected"
2425        );
2426        assert_eq!(
2427            st.status_model().prompt_text,
2428            "bravo",
2429            "the model reads the PROMPT — the minibuffer is the ex-line's store",
2430        );
2431        assert_eq!(st.cursor().line, 1, "preview re-ran and found it");
2432    }
2433
2434    #[test]
2435    fn backspacing_past_the_slash_closes_the_prompt() {
2436        let mut st = new_state_with("alpha\n");
2437        st.apply(&Action::SearchOpen(SearchDirection::Forward));
2438        st.apply(&Action::InsertChar('a'));
2439        st.apply(&Action::PromptBackspace);
2440        st.apply(&Action::PromptBackspace);
2441        assert!(!st.search.is_prompting(), "prompt closed");
2442        assert_eq!(st.modal.mode(), Mode::Normal);
2443    }
2444
2445    #[test]
2446    fn noh_clears_highlights_and_keeps_the_pattern() {
2447        let mut st = new_state_with("foo\nbar\nfoo\n");
2448        type_search(&mut st, SearchDirection::Forward, "foo");
2449        assert!(!st.search.highlights().is_empty());
2450        st.run_command("noh", &[]);
2451        assert!(st.search.highlights().is_empty(), ":noh turns them off");
2452        assert!(st.search.pattern().is_some(), "but n still works");
2453    }
2454
2455    #[test]
2456    fn noh_accepts_the_vim_aliases() {
2457        for name in ["noh", "nohl", "nohlsearch"] {
2458            let mut st = new_state_with("foo\nfoo\n");
2459            type_search(&mut st, SearchDirection::Forward, "foo");
2460            st.run_command(name, &[]);
2461            assert!(st.search.highlights().is_empty(), "{name} must clear");
2462        }
2463    }
2464
2465    #[test]
2466    fn backspace_on_the_ex_line_does_not_touch_search_state() {
2467        let mut st = new_state_with("foo\n");
2468        st.apply(&Action::ChangeMode(Mode::Command));
2469        st.apply(&Action::InsertChar('w'));
2470        st.apply(&Action::InsertChar('q'));
2471        st.apply(&Action::PromptBackspace);
2472        assert_eq!(st.status_model().prompt_text, "w");
2473        assert!(st.search.prompt().is_none(), "no search was involved");
2474    }
2475
2476    #[test]
2477    fn up_arrow_recalls_the_previous_search() {
2478        let mut st = new_state_with("alpha\nbravo\n");
2479        type_search(&mut st, SearchDirection::Forward, "bravo");
2480        st.apply(&Action::SearchOpen(SearchDirection::Forward));
2481        st.apply(&Action::PromptHistory { back: true });
2482        assert_eq!(st.search.prompt().unwrap().text(), "bravo");
2483        assert_eq!(
2484            st.status_model().prompt_text,
2485            "bravo",
2486            "display follows the prompt"
2487        );
2488    }
2489
2490    #[test]
2491    fn arrowing_back_down_restores_the_half_typed_pattern() {
2492        let mut st = new_state_with("alpha\nbravo\n");
2493        type_search(&mut st, SearchDirection::Forward, "bravo");
2494        st.apply(&Action::SearchOpen(SearchDirection::Forward));
2495        st.apply(&Action::InsertChar('a'));
2496        st.apply(&Action::PromptHistory { back: true });
2497        assert_eq!(st.search.prompt().unwrap().text(), "bravo");
2498        st.apply(&Action::PromptHistory { back: false });
2499        assert_eq!(
2500            st.search.prompt().unwrap().text(),
2501            "a",
2502            "the draft comes back"
2503        );
2504        assert_eq!(st.status_model().prompt_text, "a");
2505    }
2506
2507    #[test]
2508    fn history_arrows_do_nothing_on_the_ex_line() {
2509        let mut st = new_state_with("alpha\n");
2510        st.apply(&Action::ChangeMode(Mode::Command));
2511        st.apply(&Action::InsertChar('w'));
2512        st.apply(&Action::PromptHistory { back: true });
2513        assert_eq!(st.status_model().prompt_text, "w", "ex line untouched");
2514    }
2515
2516    fn new_state_with(text: &str) -> EditorState {
2517        let mut bufs = BufferSet::new();
2518        let id = bufs.scratch(text);
2519        EditorState::new_with_buffer(bufs, id)
2520    }
2521
2522    /// The refresh-seal driver (theory/ESCRIBA.md §Refresh-Seal): an applied
2523    /// action advances `edit_gen` (so the renderer repaints), and merely
2524    /// reading the generation does not. This is what lets `gpu.rs` gate the
2525    /// re-highlight/re-shape on a generation change — an idle frame observes an
2526    /// unchanged generation and reuses its cached buffer.
2527    #[test]
2528    fn edit_gen_advances_on_applied_action_not_on_read() {
2529        let mut s = new_state_with("hello\nworld\n");
2530        let g0 = s.edit_gen();
2531        s.apply(&Action::InsertChar('X'));
2532        assert_ne!(
2533            s.edit_gen(),
2534            g0,
2535            "an applied action must advance the refresh generation",
2536        );
2537        // Reading the generation is not a mutation — idle frames stay put.
2538        let g1 = s.edit_gen();
2539        assert_eq!(s.edit_gen(), g1, "reading edit_gen must not advance it");
2540    }
2541
2542    /// The M1 refresh node (theory/ESCRIBA.md §X): a mutation widens the typed
2543    /// `Damage` to cover exactly what changed — local for an in-place edit,
2544    /// to-end-of-document when the line count shifts — and the renderer drains
2545    /// it per frame. `Damage ⊇ changed` by construction; it never narrows.
2546    #[test]
2547    fn damage_tracks_edit_scope_and_drains() {
2548        let mut s = new_state_with("hello\nworld\n");
2549        assert!(s.damage().is_none(), "a fresh state has no damage");
2550
2551        s.apply(&Action::InsertChar('X')); // in-place edit on line 0
2552        assert_eq!(
2553            s.damage(),
2554            Damage::Lines { from: 0, to: 0 },
2555            "a local edit damages just its line",
2556        );
2557
2558        let drained = s.take_damage();
2559        assert_eq!(drained, Damage::Lines { from: 0, to: 0 });
2560        assert!(s.damage().is_none(), "take_damage drains to None");
2561
2562        s.apply(&Action::InsertChar('\n')); // splits line 0 → line count grows
2563        assert_eq!(
2564            s.damage(),
2565            Damage::Lines {
2566                from: 0,
2567                to: u32::MAX,
2568            },
2569            "a line-count change damages to end-of-document",
2570        );
2571    }
2572
2573    /// A state whose active window is a deliberately tiny viewport
2574    /// (`visible_lines` × `visible_columns`) so the scroll-to-contain
2575    /// invariant is exercised on small inputs.
2576    fn new_state_small_viewport(text: &str, vis_lines: u32, vis_cols: u32) -> EditorState {
2577        let mut s = new_state_with(text);
2578        for w in &mut s.layout.windows {
2579            w.viewport.visible_lines = vis_lines;
2580            w.viewport.visible_columns = vis_cols;
2581        }
2582        s
2583    }
2584
2585    /// The core regression invariant: the active window's viewport CONTAINS
2586    /// the cursor on BOTH axes. This is the operator's exact complaint —
2587    /// "typing past the bottom (or right) leaves the cursor off-screen" —
2588    /// made into a checkable property.
2589    fn assert_cursor_in_viewport(s: &EditorState, ctx: &str) {
2590        let w = s.layout.active_window().expect("active window");
2591        let v = w.viewport;
2592        let c = s.cursor();
2593        assert!(
2594            v.top_line <= c.line && c.line < v.top_line + v.visible_lines,
2595            "[{ctx}] cursor line {} not in vertical window [{}, {}); viewport={v:?}",
2596            c.line,
2597            v.top_line,
2598            v.top_line + v.visible_lines,
2599        );
2600        assert!(
2601            v.left_column <= c.column && c.column < v.left_column + v.visible_columns,
2602            "[{ctx}] cursor column {} not in horizontal window [{}, {}); viewport={v:?}",
2603            c.column,
2604            v.left_column,
2605            v.left_column + v.visible_columns,
2606        );
2607    }
2608
2609    fn press(kc: KeyCode) -> AppEvent {
2610        AppEvent::Key(KeyEvent {
2611            key: kc,
2612            pressed: true,
2613            modifiers: Modifiers::default(),
2614            text: None,
2615        })
2616    }
2617
2618    // ── operator-over-motion (the `dw`/`c$`/`y0` verbs) ──────────────
2619
2620    fn line0_len(s: &EditorState) -> u32 {
2621        s.buffers.get(s.active).unwrap().line_len_chars(0)
2622    }
2623
2624    #[test]
2625    fn delete_to_line_end_clears_line_and_fills_register() {
2626        let mut s = new_state_with("hello world");
2627        s.apply(&Action::ApplyOperator {
2628            op: Operator::Delete,
2629            motion: Motion::LineEnd,
2630        });
2631        assert_eq!(line0_len(&s), 0, "d$ deletes to end of line");
2632        assert_eq!(
2633            s.register(),
2634            Some("hello world"),
2635            "delete fills the register"
2636        );
2637        assert_eq!(
2638            s.cursor(),
2639            Position::ZERO,
2640            "cursor lands at the range start"
2641        );
2642    }
2643
2644    #[test]
2645    fn delete_over_right_motion_removes_one_char() {
2646        let mut s = new_state_with("abc");
2647        s.apply(&Action::ApplyOperator {
2648            op: Operator::Delete,
2649            motion: Motion::Right,
2650        });
2651        assert_eq!(
2652            s.buffers.get(s.active).unwrap().line(0).as_deref(),
2653            Some("bc")
2654        );
2655        assert_eq!(s.register(), Some("a"));
2656    }
2657
2658    #[test]
2659    fn change_to_line_end_deletes_and_enters_insert() {
2660        let mut s = new_state_with("hello world");
2661        assert_eq!(s.modal.mode(), Mode::Normal);
2662        s.apply(&Action::ApplyOperator {
2663            op: Operator::Change,
2664            motion: Motion::LineEnd,
2665        });
2666        assert_eq!(line0_len(&s), 0, "c$ deletes the range");
2667        assert_eq!(
2668            s.modal.mode(),
2669            Mode::Insert,
2670            "change enters Insert to type the replacement"
2671        );
2672        assert_eq!(
2673            s.register(),
2674            Some("hello world"),
2675            "change fills the register"
2676        );
2677    }
2678
2679    #[test]
2680    fn yank_to_line_end_fills_register_without_mutating() {
2681        let mut s = new_state_with("hello world");
2682        s.apply(&Action::ApplyOperator {
2683            op: Operator::Yank,
2684            motion: Motion::LineEnd,
2685        });
2686        assert_eq!(line0_len(&s), 11, "yank does not mutate the buffer");
2687        assert_eq!(s.register(), Some("hello world"), "yank fills the register");
2688        assert_eq!(s.modal.mode(), Mode::Normal, "yank stays in Normal");
2689    }
2690
2691    #[test]
2692    fn resolve_motion_is_the_shared_target_for_move_and_operator() {
2693        // The encapsulation proof: apply_motion (cursor move) and
2694        // apply_operator (range end) BOTH stand on resolve_motion — so a move
2695        // to LineEnd lands at exactly the position the operator deletes to.
2696        let mut s = new_state_with("hello world");
2697        let target = s.resolve_motion(Position::ZERO, Motion::LineEnd).unwrap();
2698        assert_eq!(target, Position::new(0, 11));
2699        s.apply_motion(Motion::LineEnd);
2700        assert_eq!(
2701            s.cursor(),
2702            target,
2703            "the move path resolves the same target the operator uses"
2704        );
2705    }
2706
2707    #[test]
2708    fn empty_motion_range_is_a_no_op() {
2709        // An operator over a zero-width motion (cursor already at line start)
2710        // mutates nothing and leaves the register untouched.
2711        let mut s = new_state_with("abc");
2712        s.apply(&Action::ApplyOperator {
2713            op: Operator::Delete,
2714            motion: Motion::LineStart,
2715        });
2716        assert_eq!(
2717            s.buffers.get(s.active).unwrap().line(0).as_deref(),
2718            Some("abc")
2719        );
2720        assert_eq!(s.register(), None);
2721    }
2722
2723    #[test]
2724    fn operator_then_motion_composes_through_the_pending_fsm() {
2725        // The full keymap→FSM→engine path: dispatching the `d` operator action
2726        // then a `$` motion composes `d$` via the zenmai operator-pending FSM —
2727        // the operator key alone does nothing until the motion arrives.
2728        let mut s = new_state_with("hello world");
2729        s.apply(&Action::Operator(Operator::Delete));
2730        assert_eq!(line0_len(&s), 11, "the operator key alone mutates nothing");
2731        s.apply(&Action::Move(Motion::LineEnd));
2732        assert_eq!(
2733            line0_len(&s),
2734            0,
2735            "d then $ composes d$ and deletes the line"
2736        );
2737        assert_eq!(s.register(), Some("hello world"));
2738    }
2739
2740    #[test]
2741    fn change_operator_through_fsm_enters_insert() {
2742        let mut s = new_state_with("hello world");
2743        s.apply(&Action::Operator(Operator::Change));
2744        s.apply(&Action::Move(Motion::LineEnd));
2745        assert_eq!(s.modal.mode(), Mode::Insert, "c$ deletes and enters Insert");
2746    }
2747
2748    #[test]
2749    fn lone_motion_after_no_operator_just_moves() {
2750        // Without a preceding operator the motion passes through unchanged.
2751        let mut s = new_state_with("hello world");
2752        s.apply(&Action::Move(Motion::LineEnd));
2753        assert_eq!(s.cursor(), Position::new(0, 11));
2754        assert_eq!(line0_len(&s), 11, "a bare motion never mutates");
2755    }
2756
2757    #[test]
2758    fn counted_operator_deletes_count_times() {
2759        // `3d` + a right-motion = `3dl` = delete 3 chars. The operator's count
2760        // flows through the FSM to the composed motion (the bug fix: previously
2761        // the count repeated the operator key and toggled the FSM).
2762        let mut s = new_state_with("abcdef");
2763        s.apply_counted(&Action::Operator(Operator::Delete), 3);
2764        assert_eq!(line0_len(&s), 6, "the operator key alone mutates nothing");
2765        s.apply(&Action::Move(Motion::Right));
2766        assert_eq!(
2767            s.buffers.get(s.active).unwrap().line(0).as_deref(),
2768            Some("def")
2769        );
2770    }
2771
2772    #[test]
2773    fn operator_and_motion_counts_multiply_end_to_end() {
2774        // `2d3l` = delete 2×3 = 6 chars.
2775        let mut s = new_state_with("abcdefgh");
2776        s.apply_counted(&Action::Operator(Operator::Delete), 2);
2777        s.apply_counted(&Action::Move(Motion::Right), 3);
2778        assert_eq!(
2779            s.buffers.get(s.active).unwrap().line(0).as_deref(),
2780            Some("gh")
2781        );
2782    }
2783
2784    #[test]
2785    fn bare_counted_motion_still_repeats_no_regression() {
2786        // `3j` still moves down 3 lines — the count passes through the FSM
2787        // unchanged when no operator is pending.
2788        let mut s = new_state_with("a\nb\nc\nd\ne");
2789        s.apply_counted(&Action::Move(Motion::Down), 3);
2790        assert_eq!(s.cursor().line, 3, "5j-style counted motion preserved");
2791    }
2792
2793    /// A monotonic clock for the key-repeat gate in tests — each `next()`
2794    /// jumps a full second past the previous, so every press it stamps is
2795    /// well outside the 80ms debounce window and therefore an INTENTIONAL
2796    /// press (never a storm tick). Used by tests that fire the *same*
2797    /// navigation key twice and assert editor logic, not debounce timing.
2798    struct SpacedClock(std::time::Instant);
2799    impl SpacedClock {
2800        fn new() -> Self {
2801            Self(std::time::Instant::now())
2802        }
2803        fn next(&mut self) -> std::time::Instant {
2804            self.0 += std::time::Duration::from_secs(1);
2805            self.0
2806        }
2807    }
2808
2809    #[test]
2810    fn hjkl_moves_cursor() {
2811        let mut s = new_state_with("hello\nworld");
2812        s.tick(&press(KeyCode::Char('l')));
2813        assert_eq!(s.cursor().column, 1);
2814        s.tick(&press(KeyCode::Char('j')));
2815        assert_eq!(s.cursor().line, 1);
2816        s.tick(&press(KeyCode::Char('h')));
2817        assert_eq!(s.cursor().column, 0);
2818    }
2819
2820    #[test]
2821    fn insert_mode_inserts_chars() {
2822        let mut s = new_state_with("");
2823        s.tick(&press(KeyCode::Char('i')));
2824        assert_eq!(s.modal.mode(), Mode::Insert);
2825        s.tick(&press(KeyCode::Char('h')));
2826        s.tick(&press(KeyCode::Char('i')));
2827        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "hi");
2828        assert_eq!(s.cursor().column, 2);
2829    }
2830
2831    #[test]
2832    fn esc_returns_to_normal() {
2833        let mut s = new_state_with("");
2834        s.tick(&press(KeyCode::Char('i')));
2835        s.tick(&press(KeyCode::Escape));
2836        assert_eq!(s.modal.mode(), Mode::Normal);
2837    }
2838
2839    #[test]
2840    fn count_prefix_repeats_motion() {
2841        let mut s = new_state_with("abcdefghij");
2842        s.tick(&press(KeyCode::Char('5')));
2843        s.tick(&press(KeyCode::Char('l')));
2844        assert_eq!(s.cursor().column, 5);
2845    }
2846
2847    #[test]
2848    fn close_event_requests_quit() {
2849        let mut s = new_state_with("");
2850        s.tick(&AppEvent::CloseRequested);
2851        assert!(s.quit_requested);
2852    }
2853
2854    #[test]
2855    fn word_next_jumps_past_whitespace() {
2856        let mut s = new_state_with("foo bar baz");
2857        // Two INTENTIONAL `w` presses, spaced past the key-repeat window so
2858        // the gate passes both (a real user's two taps are ≥80ms apart).
2859        let mut clk = SpacedClock::new();
2860        s.tick_at(&press(KeyCode::Char('w')), clk.next());
2861        assert_eq!(s.cursor().column, 4);
2862        s.tick_at(&press(KeyCode::Char('w')), clk.next());
2863        assert_eq!(s.cursor().column, 8);
2864    }
2865
2866    // ── Multi-key / leader pending-stroke ───────────────────────────
2867
2868    #[test]
2869    fn leader_sequence_holds_then_resolves() {
2870        let mut s = new_state_with("a\nbb\nccc");
2871        s.keymap.bind_sequence(
2872            Mode::Normal,
2873            vec![Key::Char(','), Key::Char('g')],
2874            Action::Move(Motion::DocEnd),
2875            "doc end",
2876        );
2877        // `,` begins the sequence — held pending, nothing applied yet.
2878        s.on_key(&Key::Char(','));
2879        assert_eq!(s.pending_keys, vec![Key::Char(',')]);
2880        assert_eq!(s.cursor(), Position::ZERO);
2881        // `g` completes `<leader>g` → DocEnd; pending clears.
2882        s.on_key(&Key::Char('g'));
2883        assert!(s.pending_keys.is_empty());
2884        assert_eq!(s.cursor().line, 2);
2885    }
2886
2887    #[test]
2888    fn two_key_gg_jumps_doc_start() {
2889        let mut s = new_state_with("a\nbb\nccc");
2890        s.keymap.bind_sequence(
2891            Mode::Normal,
2892            vec![Key::Char('g'), Key::Char('g')],
2893            Action::Move(Motion::DocStart),
2894            "doc start",
2895        );
2896        let mut clk = SpacedClock::new();
2897        s.tick_at(&press(KeyCode::Char('j')), clk.next());
2898        s.tick_at(&press(KeyCode::Char('j')), clk.next());
2899        assert_eq!(s.cursor().line, 2);
2900        s.on_key(&Key::Char('g')); // pending
2901        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2902        s.on_key(&Key::Char('g')); // resolve
2903        assert_eq!(s.cursor(), Position::ZERO);
2904    }
2905
2906    #[test]
2907    fn broken_sequence_aborts_and_clears_pending() {
2908        let mut s = new_state_with("hello");
2909        s.keymap.bind_sequence(
2910            Mode::Normal,
2911            vec![Key::Char('g'), Key::Char('g')],
2912            Action::Move(Motion::DocEnd),
2913            "doc end",
2914        );
2915        s.on_key(&Key::Char('g')); // pending [g]
2916        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2917        s.on_key(&Key::Char('x')); // breaks gg → abort; x is unbound → no-op
2918        assert!(s.pending_keys.is_empty());
2919        assert_eq!(s.cursor(), Position::ZERO);
2920    }
2921
2922    #[test]
2923    fn single_binding_wins_over_sequence_prefix() {
2924        // A key that is BOTH a complete single binding and the start of
2925        // a sequence fires the single binding immediately (no chord
2926        // timeout needed). Here `h` (move-left) also prefixes `hz`.
2927        let mut s = new_state_with("abcde");
2928        let mut clk = SpacedClock::new();
2929        s.tick_at(&press(KeyCode::Char('l')), clk.next());
2930        s.tick_at(&press(KeyCode::Char('l')), clk.next());
2931        assert_eq!(s.cursor().column, 2);
2932        s.keymap.bind_sequence(
2933            Mode::Normal,
2934            vec![Key::Char('h'), Key::Char('z')],
2935            Action::Move(Motion::DocEnd),
2936            "shadowed",
2937        );
2938        s.on_key(&Key::Char('h'));
2939        assert!(s.pending_keys.is_empty(), "single binding should not pend");
2940        assert_eq!(s.cursor().column, 1, "h moved left immediately");
2941    }
2942
2943    // ── tatara-lisp runtime bridge (imperative programmability) ─────
2944
2945    #[test]
2946    fn lisp_set_option_writes_live_options() {
2947        let mut s = new_state_with("");
2948        s.run_lisp(r#"(set-option "number" "true")"#).unwrap();
2949        assert_eq!(s.options.get("number").map(String::as_str), Some("true"));
2950    }
2951
2952    #[test]
2953    fn lisp_insert_modifies_buffer_and_advances_cursor() {
2954        let mut s = new_state_with("");
2955        s.run_lisp(r#"(insert "abc")"#).unwrap();
2956        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
2957        assert_eq!(s.cursor(), Position::new(0, 3));
2958    }
2959
2960    #[test]
2961    fn lisp_message_appends_to_messages() {
2962        let mut s = new_state_with("");
2963        s.run_lisp(r#"(message "hello from lisp")"#).unwrap();
2964        assert_eq!(s.messages, vec!["hello from lisp".to_string()]);
2965    }
2966
2967    #[test]
2968    fn lisp_reads_snapshot_and_branches_to_effect() {
2969        // Genuine programmability: Lisp reads the live cursor line and
2970        // an `if` decides which option to set.
2971        let mut s = new_state_with("one\ntwo\nthree");
2972        // cursor at line 0 → "top" branch
2973        s.run_lisp(r#"(if (= (cursor-line) 0) (set-option "pos" "top") (set-option "pos" "mid"))"#)
2974            .unwrap();
2975        assert_eq!(s.options.get("pos").map(String::as_str), Some("top"));
2976    }
2977
2978    #[test]
2979    fn lisp_run_command_effect_drives_registry() {
2980        // `(run-command "undo")` reaches the live command registry and
2981        // reverts a prior Lisp-driven insert — proving the RunCommand
2982        // effect dispatches through real editor commands.
2983        let mut s = new_state_with("");
2984        s.run_lisp(r#"(insert "abc")"#).unwrap();
2985        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
2986        s.run_lisp(r#"(run-command "undo")"#).unwrap();
2987        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "");
2988    }
2989
2990    #[test]
2991    fn lisp_run_command_quit_sets_quit_requested_via_typed_flag() {
2992        // The full imperative-quit path: (run-command "quit") routes
2993        // through the registry's typed `quit_requested` signal — no string
2994        // sentinel, and no minibuffer pollution (the editor stays in a
2995        // clean Normal state, which has no minibuffer at all).
2996        let mut s = new_state_with("");
2997        s.run_lisp(r#"(run-command "quit")"#).unwrap();
2998        assert!(s.quit_requested, "lisp-driven quit must set quit_requested");
2999        assert_eq!(
3000            s.modal.minibuffer(),
3001            "",
3002            "quit must not pollute any command line — Normal mode has no minibuffer",
3003        );
3004    }
3005
3006    // ── Lazy plugin activation (PluginHost) ────────────────────────
3007
3008    #[test]
3009    fn lazy_plugin_activates_on_command_trigger() {
3010        // A user plugin gated on `Command: LazyGo` has its entry applied
3011        // the first time that command runs — proving the lazy.nvim
3012        // `cmd =` model works end-to-end against live editor state.
3013        let mut s = new_state_with("");
3014        s.register_lazy_plugin(
3015            "user-lazy",
3016            vec![LazyTrigger::Command("LazyGo".into())],
3017            r#"(defoption :name "lazy-loaded" :value "yes")
3018               (defcmd :name "LazyGo" :description "noop" :action "editor.noop")"#,
3019        );
3020        assert_eq!(s.plugin_host.pending(), 1);
3021        assert!(
3022            s.options.get("lazy-loaded").is_none(),
3023            "entry not applied yet"
3024        );
3025
3026        // Drive the command through the public imperative path.
3027        s.run_lisp(r#"(run-command "LazyGo")"#).unwrap();
3028
3029        assert_eq!(
3030            s.options.get("lazy-loaded").map(String::as_str),
3031            Some("yes"),
3032            "the command trigger applied the plugin's entry",
3033        );
3034        assert_eq!(s.plugin_host.pending(), 0, "plugin activated exactly once");
3035    }
3036
3037    #[test]
3038    fn lazy_plugin_activates_on_filetype() {
3039        let mut s = new_state_with("");
3040        s.register_lazy_plugin(
3041            "user-rust",
3042            vec![LazyTrigger::FileType("rust".into())],
3043            r#"(defoption :name "rust-plugin" :value "on")"#,
3044        );
3045        let n = s.activate_filetype_plugins("rust");
3046        assert_eq!(n, 1);
3047        assert_eq!(s.options.get("rust-plugin").map(String::as_str), Some("on"));
3048        // A second open of the same filetype is a no-op (one-shot).
3049        assert_eq!(s.activate_filetype_plugins("rust"), 0);
3050    }
3051
3052    #[test]
3053    fn cached_vm_serves_multiple_run_lisp_calls() {
3054        let mut s = new_state_with("");
3055        s.run_lisp(r#"(message "one")"#).unwrap();
3056        assert!(
3057            s.lisp_vm.is_some(),
3058            "VM should be cached after first run_lisp"
3059        );
3060        s.run_lisp(r#"(message "two")"#).unwrap();
3061        assert_eq!(s.messages, vec!["one".to_string(), "two".to_string()]);
3062    }
3063
3064    #[test]
3065    fn lisp_define_persists_across_run_lisp_calls() {
3066        // The cached VM's top-level env persists across calls (REPL
3067        // semantics): a `define` in one call is visible in the next.
3068        let mut s = new_state_with("");
3069        s.run_lisp(r#"(define greeting "hi")"#).unwrap();
3070        s.run_lisp(r#"(message greeting)"#).unwrap();
3071        assert_eq!(s.messages, vec!["hi".to_string()]);
3072    }
3073
3074    #[test]
3075    fn snapshot_is_isolated_within_one_run_lisp_call_and_refreshes_across() {
3076        // Within ONE call a program cannot observe its own writes — the
3077        // read snapshot is captured before eval, effects apply after. A
3078        // later call sees the refreshed snapshot.
3079        let mut s = new_state_with("");
3080        s.run_lisp(
3081            r#"(insert "ab") (set-option "col" (if (= (cursor-column) 0) "stale-zero" "live"))"#,
3082        )
3083        .unwrap();
3084        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "ab");
3085        assert_eq!(
3086            s.options.get("col").map(String::as_str),
3087            Some("stale-zero"),
3088            "cursor-column within the same call reads the pre-eval snapshot",
3089        );
3090        // After the first call the cursor advanced to column 2; the next
3091        // call's snapshot reflects it.
3092        s.run_lisp(r#"(set-option "col2" (if (= (cursor-column) 2) "live-two" "other"))"#)
3093            .unwrap();
3094        assert_eq!(
3095            s.options.get("col2").map(String::as_str),
3096            Some("live-two"),
3097            "a later call sees the refreshed snapshot",
3098        );
3099    }
3100
3101    #[test]
3102    fn insert_text_effect_multiline_lands_cursor_on_last_line() {
3103        let mut s = new_state_with("");
3104        s.apply_host_effects(vec![Negai::InsertText("foo\nbar".to_string())]);
3105        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "foo\nbar");
3106        assert_eq!(s.cursor(), Position::new(1, 3));
3107    }
3108
3109    #[test]
3110    fn visual_mode_sequence_resolves() {
3111        let mut s = new_state_with("abc");
3112        s.modal.enter(Mode::Visual);
3113        s.keymap.bind_sequence(
3114            Mode::Visual,
3115            vec![Key::Char('g'), Key::Char('e')],
3116            Action::Move(Motion::DocEnd),
3117            "ge",
3118        );
3119        s.on_key(&Key::Char('g'));
3120        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
3121        s.on_key(&Key::Char('e'));
3122        assert!(s.pending_keys.is_empty());
3123        assert_eq!(
3124            s.cursor().column,
3125            3,
3126            "ge resolved to doc-end in visual mode"
3127        );
3128    }
3129
3130    #[test]
3131    fn sequence_abort_with_bound_breaking_key_redispatches() {
3132        // gg is a sequence; `l` (move-right) is a bound single key. After
3133        // `g` pends, `l` breaks gg, aborts, and is re-dispatched fresh.
3134        let mut s = new_state_with("abcde");
3135        s.keymap.bind_sequence(
3136            Mode::Normal,
3137            vec![Key::Char('g'), Key::Char('g')],
3138            Action::Move(Motion::DocEnd),
3139            "gg",
3140        );
3141        s.on_key(&Key::Char('g'));
3142        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
3143        s.on_key(&Key::Char('l'));
3144        assert!(s.pending_keys.is_empty());
3145        assert_eq!(
3146            s.cursor().column,
3147            1,
3148            "the breaking key l should re-dispatch as move-right",
3149        );
3150    }
3151
3152    // ── Viewport-follows-cursor invariant (both axes) ───────────────
3153
3154    #[test]
3155    fn viewport_contains_cursor_after_every_op() {
3156        // Tiny window: 5 visible lines × 10 visible columns. Drive a
3157        // representative scripted sequence and assert the viewport contains
3158        // the cursor after EVERY mutating step.
3159        let mut s = new_state_small_viewport("", 5, 10);
3160        assert_cursor_in_viewport(&s, "initial");
3161
3162        // Enter insert mode and type 30 newline-separated lines — this is
3163        // the exact "type past the bottom" complaint.
3164        s.tick(&press(KeyCode::Char('i')));
3165        assert_eq!(s.modal.mode(), Mode::Insert);
3166        for line in 0..30u32 {
3167            for c in "line".chars() {
3168                s.tick(&press(KeyCode::Char(c)));
3169                assert_cursor_in_viewport(&s, "typing chars");
3170            }
3171            s.tick(&press(KeyCode::Enter));
3172            assert_cursor_in_viewport(&s, &format!("newline after line {line}"));
3173        }
3174
3175        // Type a long (200-char) line — the "type past the right edge"
3176        // complaint. The cursor must stay horizontally visible the whole way.
3177        for i in 0..200u32 {
3178            s.tick(&press(KeyCode::Char('x')));
3179            assert_cursor_in_viewport(&s, &format!("long-line char {i}"));
3180        }
3181
3182        // Multi-line insert_text effect (the `(insert …)` Lisp path).
3183        s.insert_text("alpha\nbeta\ngamma delta epsilon zeta");
3184        assert_cursor_in_viewport(&s, "insert_text multiline");
3185
3186        // Back to normal mode and move in all directions / to extremes.
3187        s.tick(&press(KeyCode::Escape));
3188        assert_eq!(s.modal.mode(), Mode::Normal);
3189        for m in [
3190            Motion::DocStart,
3191            Motion::DocEnd,
3192            Motion::Down,
3193            Motion::Down,
3194            Motion::Up,
3195            Motion::Right,
3196            Motion::Right,
3197            Motion::Left,
3198            Motion::LineEnd,
3199            Motion::LineStart,
3200            Motion::GotoLine(1),
3201            Motion::GotoLine(40),
3202            Motion::PageDown,
3203            Motion::PageUp,
3204        ] {
3205            s.apply_motion(m);
3206            assert_cursor_in_viewport(&s, &format!("after motion {m:?}"));
3207        }
3208
3209        // Undo many times — the buffer shrinks; the viewport must re-follow
3210        // the (now clamped) cursor.
3211        for i in 0..50u32 {
3212            s.apply(&Action::Undo);
3213            assert_cursor_in_viewport(&s, &format!("undo {i}"));
3214        }
3215        // Redo back up.
3216        for i in 0..50u32 {
3217            s.apply(&Action::Redo);
3218            assert_cursor_in_viewport(&s, &format!("redo {i}"));
3219        }
3220    }
3221
3222    #[test]
3223    fn insert_at_eof_keeps_cursor_in_bounds() {
3224        // Inserting at the end of the buffer must leave the cursor clamped
3225        // to a valid position (and inside the viewport).
3226        let mut s = new_state_small_viewport("abc", 5, 10);
3227        s.apply_motion(Motion::DocEnd);
3228        s.tick(&press(KeyCode::Char('i')));
3229        s.tick(&press(KeyCode::Char('d')));
3230        let buf = s.buffers.get(s.active).unwrap();
3231        let clamped = buf.clamp(s.cursor());
3232        assert_eq!(
3233            s.cursor(),
3234            clamped,
3235            "cursor must be clamped in-bounds at EOF"
3236        );
3237        assert_cursor_in_viewport(&s, "insert at eof");
3238    }
3239
3240    #[test]
3241    fn count_prefix_then_sequence_repeats() {
3242        // `2` then `gj` (→ move-down) repeats the resolved action twice.
3243        let mut s = new_state_with("a\nb\nc\nd\ne");
3244        s.keymap.bind_sequence(
3245            Mode::Normal,
3246            vec![Key::Char('g'), Key::Char('j')],
3247            Action::Move(Motion::Down),
3248            "gj",
3249        );
3250        s.on_key(&Key::Char('2'));
3251        s.on_key(&Key::Char('g'));
3252        s.on_key(&Key::Char('j'));
3253        assert_eq!(s.cursor().line, 2, "count 2 should repeat the gj motion");
3254    }
3255
3256    // ── Key-repeat gate (awase::KeyRepeatGate) ──────────────────────────
3257
3258    #[test]
3259    fn held_key_repeat_storm_is_debounced_in_normal_mode() {
3260        // The audit's exact complaint: holding `j` floods motion events
3261        // and thrashes the viewport. Simulate an OS key-repeat storm — 20
3262        // identical `j` KeyDowns at 50ms intervals (typical repeat cadence)
3263        // — and assert only the gated subset (one per 80ms window) actually
3264        // moves the cursor.
3265        let mut s = new_state_with(&"x\n".repeat(40));
3266        let t0 = std::time::Instant::now();
3267        let mut delivered = 0u32;
3268        for i in 0..20u32 {
3269            let before = s.cursor().line;
3270            s.tick_at(
3271                &press(KeyCode::Char('j')),
3272                t0 + std::time::Duration::from_millis(u64::from(i) * 50),
3273            );
3274            if s.cursor().line != before {
3275                delivered += 1;
3276            }
3277        }
3278        // 20 events over ~1s at 50ms spacing, 80ms gate ⇒ ~13 pass — far
3279        // fewer than the 20 the ungated path would have applied.
3280        assert!(
3281            (10..=14).contains(&delivered),
3282            "expected the storm debounced to ~13 moves, got {delivered}",
3283        );
3284        assert!(
3285            delivered < 20,
3286            "the gate must drop SOME storm ticks, not pass all 20",
3287        );
3288    }
3289
3290    #[test]
3291    fn spaced_intentional_taps_all_pass() {
3292        // Intentional taps spaced past the debounce window must ALL reach
3293        // the editor — the gate filters storms, never deliberate input.
3294        let mut s = new_state_with(&"x\n".repeat(10));
3295        let t0 = std::time::Instant::now();
3296        for i in 0..5u32 {
3297            s.tick_at(
3298                &press(KeyCode::Char('j')),
3299                // 100ms apart — comfortably past the 80ms window.
3300                t0 + std::time::Duration::from_millis(u64::from(i) * 100),
3301            );
3302        }
3303        assert_eq!(s.cursor().line, 5, "all 5 spaced `j` taps moved the cursor");
3304    }
3305
3306    #[test]
3307    fn distinct_keys_have_independent_clocks() {
3308        // Holding `j` must not block a simultaneous `l` — the gate keys on
3309        // the Key, so independent keys have independent windows.
3310        let mut s = new_state_with("abc\ndef\nghi");
3311        let t = std::time::Instant::now();
3312        s.tick_at(&press(KeyCode::Char('j')), t);
3313        // `j` again within the window is dropped…
3314        s.tick_at(
3315            &press(KeyCode::Char('j')),
3316            t + std::time::Duration::from_millis(10),
3317        );
3318        assert_eq!(s.cursor().line, 1, "second `j` within window dropped");
3319        // …but `l` at the same instant passes (its own clock).
3320        s.tick_at(
3321            &press(KeyCode::Char('l')),
3322            t + std::time::Duration::from_millis(10),
3323        );
3324        assert_eq!(s.cursor().column, 1, "`l` is not blocked by `j`'s clock");
3325    }
3326
3327    // ── Cursors newtype is the single cursor home ──────────────────────
3328
3329    #[test]
3330    fn cursor_home_preserves_single_cursor_behavior() {
3331        // The typed `Cursors` wrapper behaves exactly like the old bare
3332        // `Position` field for single-cursor editing: the read accessor
3333        // tracks every mutation routed through `set_cursor`, and there is
3334        // exactly one caret.
3335        let mut s = new_state_with("hello\nworld\nthere");
3336        assert_eq!(s.cursor(), Position::ZERO);
3337        assert_eq!(s.cursors.count(), 1, "phase-1 holds exactly one caret");
3338
3339        s.apply_motion(Motion::Down);
3340        s.apply_motion(Motion::Right);
3341        s.apply_motion(Motion::Right);
3342        assert_eq!(s.cursor(), Position::new(1, 2));
3343        // Still a single caret after a sequence of motions.
3344        assert_eq!(s.cursors.count(), 1);
3345
3346        // The accessor is the SAME value the viewport-follow path read.
3347        let w = s.layout.active_window().unwrap();
3348        assert!(w.viewport.top_line <= s.cursor().line);
3349    }
3350
3351    #[test]
3352    fn insert_mode_is_ungated_so_repeat_typing_works() {
3353        // Holding a key to repeat-type a character is intended in Insert
3354        // mode — the gate must NOT suppress it. 10 rapid identical `x`
3355        // keystrokes at the same instant must all land as text.
3356        let mut s = new_state_with("");
3357        s.tick(&press(KeyCode::Char('i')));
3358        assert_eq!(s.modal.mode(), Mode::Insert);
3359        let t = std::time::Instant::now();
3360        for _ in 0..10 {
3361            s.tick_at(&press(KeyCode::Char('x')), t);
3362        }
3363        assert_eq!(
3364            s.buffers.get(s.active).unwrap().to_string(),
3365            "xxxxxxxxxx",
3366            "insert-mode repeat typing is ungated",
3367        );
3368    }
3369}