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};
17
18/// What one key meant to the operator-pending object layer.
19enum ObjectKey {
20    /// Swallowed — the key began an object and nothing runs yet.
21    Consumed,
22    /// The object is complete; run this.
23    Compose(Action),
24}
25pub use status::{PromptKind, StatusModel};
26
27use std::collections::HashMap;
28
29use awase::KeyRepeatGate;
30use escriba_buffer::BufferSet;
31use escriba_buffer::TextRev;
32use escriba_command::CommandRegistry;
33use escriba_core::{
34    Action, Anchored, Bound, BufferId, Cursors, Damage, Edit, EditGen, HighlightEffect, JumpList,
35    Mode, Motion, Operator, Position, Range, TextEffect, WindowId,
36};
37use escriba_input::{InputOutcome, translate_app_event};
38use escriba_keymap::{Key, Keymap};
39use escriba_madoguchi::{Negai, Outcome};
40use escriba_mode::ModalState;
41use escriba_search::{Direction as SearchDirection, MatchCount, SearchState};
42use escriba_ui::chrome::{ChromePalette, FleetTheme};
43use escriba_ui::splash::Splash;
44use escriba_ui::{Layout, Viewport, Window};
45use escriba_vm::{EditorSnapshot, EscribaHost, EscribaVm, VmError};
46use madori::AppEvent;
47use std::time::Instant;
48
49/// Full editor state — the single Rust value the binary hands to the
50/// renderer each frame.
51pub struct EditorState {
52    pub buffers: BufferSet,
53    pub modal: ModalState,
54    /// Search session — the committed pattern, its matches, the live `/`
55    /// prompt and history. Owns no buffer or cursor; it answers questions
56    /// about text and this runtime applies the answers.
57    pub search: SearchState,
58    pub keymap: Keymap,
59    pub commands: CommandRegistry,
60    pub layout: Layout,
61    pub active: BufferId,
62    /// The single typed home for cursor state. Phase-1 holds one primary
63    /// [`Position`]; reads go through [`Self::cursor`], writes through
64    /// [`Self::set_cursor`] → [`Cursors::set_primary`]. There is no loose
65    /// `Position` field beside an unused multi-caret type to desync.
66    cursors: Cursors,
67    pub quit_requested: bool,
68    /// Messages surfaced to the user (status line / `:messages`) — the
69    /// sink for the tatara-lisp `(message …)` effect and other feedback.
70    pub messages: Vec<String>,
71    /// Which match the cursor last landed on (0-based) — the `[3/17]`
72    /// numerator, ANCHORED to the text revision it was computed against.
73    ///
74    /// The anchor is what removes the manual invalidation this field used to
75    /// need. An ordinal indexes a match set; when the text changes the set
76    /// changes underneath it and the number silently means something else.
77    /// Reading through `Anchored::get(current_rev)` makes that a `None`, so
78    /// forgetting to clear is no longer a thing that can be forgotten.
79    search_at: Option<Anchored<usize, TextRev>>,
80    /// The last text change, for `.`.
81    ///
82    /// An action plus whatever was typed while it held Insert open. Both
83    /// halves are needed: `cw` alone is not a change, it is the FIRST HALF of
84    /// one — the text that followed is the rest, and replaying without it
85    /// would delete a word and leave the buffer in Insert.
86    last_change: Option<LastChange>,
87    /// True while an insert session belonging to `last_change` is open, so
88    /// typed characters are appended to it. Cleared on leaving Insert.
89    recording_insert: bool,
90
91    /// Where the cursor was before each far jump — `<C-o>` / `<C-i>`.
92    /// Search commits, `n`/`N` and `*`/`#` all record into it, which is what
93    /// makes a search a place you can come back from.
94    pub jumps: JumpList,
95    /// Generic editor option store (name → value). Written by the
96    /// tatara-lisp `(set-option …)` effect and the declarative
97    /// `defoption` apply path; typed accessors layer on top later.
98    pub options: HashMap<String, String>,
99    /// Cached embedded tatara-lisp runtime, built lazily on first
100    /// `run_lisp`. Caching avoids re-installing the ~175-definition full
101    /// stdlib on every call; the interpreter's top-level env also
102    /// persists across calls, giving REPL-like session semantics (an
103    /// earlier `(define …)` is visible to a later `run_lisp`).
104    lisp_vm: Option<EscribaVm>,
105    /// Keys accumulated for an in-progress multi-key sequence — e.g.
106    /// holding `[,, f]` while waiting for the final key of
107    /// `<leader>ff`. Empty when not mid-sequence. Lives on
108    /// `EditorState` (not `ModalState`) so `escriba-mode` needn't
109    /// depend on `escriba-keymap`'s `Key`.
110    pub pending_keys: Vec<Key>,
111    /// Per-key debouncer for OS key-repeat storms. Holding `j`/`l` makes
112    /// the windowing system deliver one `KeyDown` per repeat tick
113    /// (~30-50ms); without a gate those flood the motion path and thrash
114    /// the viewport. The gate lets ONE event per `min_interval` (80ms
115    /// default — ~12 intentional taps/sec still pass) reach the editor in
116    /// the navigation modes. The fleet primitive (`awase::KeyRepeatGate`,
117    /// the same one mado uses) is reused — not reinvented.
118    repeat_gate: KeyRepeatGate<Key>,
119    /// Runtime lazy-activation host for USER plugin caixas (the bundled
120    /// default catalog is applied eagerly at boot, not through here).
121    /// A command / filetype-open / event fires the matching plugins'
122    /// entries through the escriba-lisp apply paths. See [`PluginHost`].
123    pub plugin_host: PluginHost,
124    /// The unnamed register — the home for text an operator yanks or
125    /// deletes (`Operator::leaves_register`). `None` until the first
126    /// register-leaving operator runs. Phase-1 holds the single unnamed
127    /// register; named registers (`"ay`) layer on later.
128    register: Option<String>,
129    /// The operator-pending FSM (`d`/`c`/`y` then a motion → `dw`/`c$`/`y0`),
130    /// standing on the fleet `zenmai` Mealy-machine primitive. Every dispatched
131    /// action passes through it; only an operator-then-motion pair is rewritten
132    /// into an [`Action::ApplyOperator`].
133    op_pending: zenmai::Stateful<OperatorPending>,
134    /// Operator-pending OBJECT selection, held at the KEY layer.
135    ///
136    /// `Some(around)` means `d` + `i`/`a` have been pressed and the NEXT key
137    /// names the object. It lives here rather than in the operator FSM
138    /// because the FSM sees `Action`s and this decision needs the KEY: `a`
139    /// and every bracket are unbound in Normal, so they all arrive as
140    /// `Action::Pending` with the character already discarded. vim has a
141    /// whole operator-pending keymap for the same reason.
142    pending_object: Option<bool>,
143    /// Monotonic refresh-generation stamp — the root of the sealed refresh
144    /// tree (`theory/ESCRIBA.md` §Refresh-Seal). Bumped on every applied
145    /// action + resize; the renderer gates on it so an idle frame does zero
146    /// re-highlight / re-shape, and a stale frame is unreachable.
147    edit_gen: EditGen,
148    /// The accumulated dirty region since the renderer last drained it (M1).
149    /// Only ever widened via [`Damage::join`] at the mutation funnel, so it
150    /// always covers the changed region (`Damage ⊇ changed`); the renderer
151    /// drains it with [`take_damage`](Self::take_damage) to scope its work.
152    damage: Damage,
153    /// The theme every face paints with.
154    ///
155    /// ONE owner. Before this, `(deftheme :preset …)` parsed, validated,
156    /// resolved to a real `FleetTheme` — and then nothing consumed it,
157    /// because each renderer called `ChromePalette::prescribed()` at every
158    /// paint site. The declaration was honoured on paper only. Holding it
159    /// here means a face reads the operator's theme the same way it reads
160    /// the cursor: from the state, per frame.
161    theme: FleetTheme,
162    /// `theme` resolved to concrete colours — cached because it is a plain
163    /// `Copy` struct read many times per frame, and re-derived only in
164    /// [`set_theme`](Self::set_theme), so the two cannot disagree.
165    chrome: ChromePalette,
166    /// How deep the current command dispatch is nested.
167    ///
168    /// `Negai::RunCommand` lets a command invoke a command, which is useful
169    /// and which can also recurse forever. The budget makes the runaway
170    /// bounded and REPORTED rather than a stack overflow — the difference
171    /// between a typed refusal and the editor dying under the operator.
172    dispatch_depth: u8,
173    /// Every live result list — diagnostics, hunks, grep hits, TODOs.
174    ///
175    /// Public so a producer outside the runtime can publish into it once the
176    /// courier lands; today the only producer is the marker scan.
177    pub results: escriba_shirube::ListRegistry,
178    /// The open picker, if any.
179    ///
180    /// `Option<Picker>` on the state, exactly like `splash` — deliberately
181    /// NOT a `Mode` variant. A mode is a state keys are interpreted IN; this
182    /// is a surface that OWNS keys while it is up, which is a different
183    /// thing and composes differently with the keymap.
184    picker: Option<escriba_ui::picker::Picker>,
185    /// The git-index generation. See [`world`](Self::world) — every axis the
186    /// world can move is emitted unconditionally, so a producer anchoring on
187    /// one is not born permanently stale.
188    index_rev: escriba_shirube::IndexRev,
189    /// The external-session generation (LSP restart, debug session, test run).
190    session_gen: escriba_shirube::SessionGen,
191    /// Extension → language facts, populated from `(defmode …)`.
192    ///
193    /// The consumer `:commentstring` never had. Public so the binary's apply
194    /// pass can fill it the way it fills the keymap and the option store.
195    pub filetypes: escriba_core::FiletypeTable,
196    /// The start screen, while it is up.
197    ///
198    /// `Some` only between boot and the first keypress, and only when the
199    /// editor opened with no file. It is deliberately NOT a `Mode`: a mode
200    /// is a state keys are interpreted *in*, and the splash interprets
201    /// exactly one key before it is gone. Modelling it as `Option<Splash>`
202    /// keeps the modal state machine's variant set — and every exhaustive
203    /// match over it — untouched.
204    splash: Option<Splash>,
205}
206
207/// What the start screen did with a keypress.
208///
209/// Total, and matched exhaustively at its one call site, so a future
210/// outcome (a menu that opens a submenu, say) is a compile error rather
211/// than a key that silently falls through to the buffer.
212enum SplashKey {
213    /// No start screen is up — the key is the buffer's.
214    NotShowing,
215    /// The key selected a menu entry; run this.
216    Ran(Action),
217    /// The screen is gone and the key was not a menu key, so it still
218    /// means whatever it normally means. Anything else would make the
219    /// first keystroke after boot vanish.
220    Dismissed,
221}
222
223// ─── The counter, and the one place slips become mutations ───────────────
224
225/// `EditorState` read through the counter.
226///
227/// Borrowed, never copied: building it is free, so a command dispatch does
228/// not pay for a snapshot of the buffers.
229pub struct EditorWindow<'a> {
230    state: &'a EditorState,
231}
232
233impl escriba_madoguchi::CursorView for EditorWindow<'_> {
234    fn position(&self) -> Position {
235        self.state.cursor()
236    }
237    fn mode(&self) -> Mode {
238        self.state.modal.mode()
239    }
240}
241
242impl escriba_madoguchi::SyntaxView for EditorWindow<'_> {
243    fn filetype(&self) -> Option<&escriba_core::Filetype> {
244        let path = self.state.buffers.get(self.state.active)?.path.as_deref()?;
245        self.state.filetypes.resolve(path)
246    }
247}
248
249impl escriba_madoguchi::SearchView for EditorWindow<'_> {
250    fn pattern(&self) -> Option<&str> {
251        self.state.search.committed_pattern()
252    }
253    fn match_count(&self) -> Option<usize> {
254        // `None` means "nothing committed", which is not the same as zero
255        // matches — a distinction the status line already makes and that a
256        // handler must not have to re-derive.
257        self.state
258            .search
259            .committed_pattern()
260            .map(|_| self.state.search.match_count())
261    }
262    fn is_prompting(&self) -> bool {
263        self.state.search.is_prompting()
264    }
265}
266
267impl escriba_madoguchi::Snapshot for EditorWindow<'_> {
268    fn active(&self) -> Option<&dyn escriba_madoguchi::BufferView> {
269        self.buffer(self.state.active)
270    }
271    fn buffer(&self, id: BufferId) -> Option<&dyn escriba_madoguchi::BufferView> {
272        self.state
273            .buffers
274            .get(id)
275            .map(|b| b as &dyn escriba_madoguchi::BufferView)
276    }
277    fn buffer_ids(&self) -> Vec<BufferId> {
278        self.state.buffers.ids()
279    }
280    fn cursor(&self) -> &dyn escriba_madoguchi::CursorView {
281        self
282    }
283    fn option(&self, name: &str) -> Option<&str> {
284        self.state.options.get(name).map(String::as_str)
285    }
286    fn search(&self) -> &dyn escriba_madoguchi::SearchView {
287        self
288    }
289    fn syntax(&self) -> &dyn escriba_madoguchi::SyntaxView {
290        self
291    }
292}
293
294impl EditorState {
295    /// A read-only window onto this editor.
296    #[must_use]
297    pub fn window(&self) -> EditorWindow<'_> {
298        EditorWindow { state: self }
299    }
300
301    /// Honour an [`Outcome`] — the ONLY place slips become mutations.
302    ///
303    /// Every `&mut self` in the dispatch path lives here. A command cannot
304    /// reach editor state, so if the editor ends up in a state nobody
305    /// designed, this function is where it happened; that narrowing is the
306    /// whole return on the seam.
307    ///
308    /// A failed outcome's slips are DROPPED rather than half-applied: a
309    /// handler that reported failure has no business also mutating, and
310    /// applying part of what it asked for is how an editor reaches a state
311    /// nobody designed.
312    pub fn interpret(&mut self, outcome: Outcome) {
313        if let Some(m) = outcome.verdict.message() {
314            self.messages.push(m.to_string());
315            self.damage = self.damage.join(Damage::Viewport);
316            self.bump_gen();
317        }
318        if outcome.verdict.is_failure() {
319            return;
320        }
321        for slip in outcome.slips {
322            self.honour(slip);
323        }
324    }
325
326    /// Lower an [`Action`] to slips, when it has an exact slip equivalent.
327    ///
328    /// `None` means "editor mechanics" — 23 of the 30 variants are prompt
329    /// editing, the operator-pending FSM, motion resolution, the jumplist,
330    /// the dot register. Those are the KEYMAP's vocabulary, not the AUTHORED
331    /// one, and forcing them into `Negai` would put `PromptClearToStart` and
332    /// `SearchPreviewStep` in front of every plugin author and make the
333    /// capability question meaningless (what capability does a caret move
334    /// read?). One type serving two vocabularies is the mistake this avoids.
335    ///
336    /// The plan's M3 predicate was "apply_resolved contains zero `self.`
337    /// mutations", which would have forced exactly that. Amended: the
338    /// invariant worth having is ONE IMPLEMENTATION PER MUTATION, not one
339    /// vocabulary. See docs/backlog-plan.md §V Phase 1.
340    fn lower(action: &Action, active: BufferId) -> Option<Vec<Negai>> {
341        Some(match action {
342            Action::Quit => vec![Negai::Quit],
343            Action::ClearSearchHighlight => vec![Negai::ClearSearchHighlight],
344            Action::Save => vec![Negai::Save { buffer: active }],
345            Action::Undo => vec![Negai::Undo { buffer: active }],
346            Action::Redo => vec![Negai::Redo { buffer: active }],
347            // `apply_edit` was a STUB that did nothing, so this action was a
348            // silent no-op while `Negai::Edit` applied for real. Lowering it
349            // makes keymap-originated edits work for the first time — and
350            // nothing binds it today, so the duplication goes away at zero
351            // risk.
352            Action::Edit(edit) => vec![Negai::Edit {
353                buffer: active,
354                edit: edit.clone(),
355            }],
356            _ => return None,
357        })
358    }
359
360    /// What the world currently is, for freshness.
361    ///
362    /// One text axis per open buffer. A list sealed against this is fresh
363    /// exactly while the buffers it depends on are unchanged — and a buffer
364    /// that has since CLOSED drops out, which makes lists about it stale
365    /// rather than silently kept.
366    #[must_use]
367    pub fn world(&self) -> escriba_shirube::Anchor {
368        let mut a = escriba_shirube::Anchor::new();
369        for id in self.buffers.ids() {
370            if let Some(b) = self.buffers.get(id) {
371                a = a.on(escriba_shirube::Axis::Text(id, b.text_rev()));
372            }
373        }
374        // Every axis the world can move, ALWAYS present — not only the ones
375        // some producer happens to use today.
376        //
377        // `Anchor::is_fresh` treats an ABSENT axis as stale, deliberately:
378        // unknowable is not unchanged. The consequence, unnoticed until a
379        // recon pass went looking, is that a list anchored on an axis this
380        // function never emits is born PERMANENTLY stale — `]c` would answer
381        // "that list is out of date" forever, and nothing would say why. The
382        // two-axis model was built for git hunks and then only ever fed one
383        // axis.
384        //
385        // Emitting them unconditionally means a producer can anchor on any
386        // axis and get an honest answer. A counter that never moves reads as
387        // "unchanged", which is exactly right for a plane escriba does not
388        // track yet.
389        a = a.on(escriba_shirube::Axis::Index(self.index_rev));
390        a.on(escriba_shirube::Axis::Session(self.session_gen))
391    }
392
393    /// Where the cursor is, WITH the buffer it is in.
394    ///
395    /// Every jumplist push goes through this. A bare `Position` is what let
396    /// `<C-o>` return to the right line in the wrong file.
397    #[must_use]
398    pub fn spot(&self) -> escriba_core::Spot {
399        escriba_core::Spot::new(self.active, self.cursor())
400    }
401
402    /// Move to a `Spot`, switching buffer if it names another one.
403    ///
404    /// The read half of [`spot`](Self::spot). `<C-o>` and `<C-i>` both land
405    /// here so neither can forget the buffer.
406    fn goto_spot(&mut self, s: escriba_core::Spot) {
407        if s.buffer != self.active && self.buffers.get(s.buffer).is_some() {
408            self.active = s.buffer;
409        }
410        let clamped = self
411            .buffers
412            .get(self.active)
413            .map_or(s.pos, |b| b.clamp(s.pos));
414        self.set_cursor(clamped);
415    }
416
417    /// Advance the git-index generation — every list anchored on
418    /// `Axis::Index` goes stale.
419    ///
420    /// Not called yet; a git layer calls it after a stage/reset. Present so
421    /// the axis is WIRED rather than declared, because an axis nothing can
422    /// move is indistinguishable from an axis that does not exist.
423    pub fn bump_index_rev(&mut self) {
424        self.index_rev = escriba_shirube::IndexRev(self.index_rev.0.wrapping_add(1));
425    }
426
427    /// Advance the external-session generation — LSP restart, debug session,
428    /// test-runner invocation. See [`bump_index_rev`](Self::bump_index_rev).
429    pub fn bump_session_gen(&mut self) {
430        self.session_gen = escriba_shirube::SessionGen(self.session_gen.0.wrapping_add(1));
431    }
432
433    /// Move the cursor to the next/previous finding in `list`.
434    ///
435    /// Reports the wrap, because `n`/`N` do and a reader losing their place
436    /// in a long file is the same problem either way.
437    fn walk_list(&mut self, list: &str, forward: bool) {
438        let world = self.world();
439        let Some(result) = self.results.get(list) else {
440            let mut m = String::from("no list named ");
441            m.push_str(list);
442            self.messages.push(m);
443            return;
444        };
445        if result.is_stale(&world) {
446            self.messages
447                .push("that list is out of date — run it again".to_string());
448            return;
449        }
450        let here = (Some(self.active), self.cursor().line);
451        let Some(found) = result.step(&world, here, forward, escriba_shirube::Bound::Exclusive)
452        else {
453            let mut m = String::from("no entries in ");
454            m.push_str(list);
455            self.messages.push(m);
456            return;
457        };
458        let site = found.site.clone();
459        let msg = found.message.clone();
460        self.jump_to_site(&site);
461        self.messages.push(msg);
462    }
463
464    /// Move the cursor to a located finding's SITE — the one operation that
465    /// cannot drop the buffer half of a location.
466    ///
467    /// A `Site` is `(buffer, range)`. Every jumper before this re-derived the
468    /// move itself and clamped against `self.active`, so a finding in another
469    /// file landed on the right LINE in the WRONG file. `on_line` and
470    /// `worst_on_line` already filter by buffer, so the gutter and the walker
471    /// disagreed — latent only because the first producer scanned one buffer.
472    ///
473    /// Every future producer (diagnostics, hunks, grep hits, test failures)
474    /// is cross-file by nature, which is why this is a shared operation
475    /// rather than a fix at the one call site that has it wrong today.
476    ///
477    /// Always a FAR jump: it pushes the jumplist, so `<C-o>` returns from a
478    /// `]t` exactly as it returns from an `n`.
479    pub fn jump_to_site(&mut self, site: &escriba_shirube::Site) {
480        self.jumps.push(self.spot());
481        // Switch buffers FIRST — clamping against the wrong buffer is how the
482        // position gets silently mangled before anyone can notice.
483        if let Some(target) = site.buffer {
484            if target != self.active && self.buffers.get(target).is_some() {
485                self.active = target;
486                self.refollow_cursor();
487            }
488        }
489        let to = site.range.start;
490        let clamped = self.buffers.get(self.active).map_or(to, |b| b.clamp(to));
491        self.set_cursor(clamped);
492    }
493
494    /// Close a buffer, keeping "there is always an active buffer" true.
495    ///
496    /// The invariant is the whole reason this is not just
497    /// `self.buffers.close(id)`. `EditorState::active` is a `BufferId`, not
498    /// an `Option`, so a dangling active is not a degraded state — it is a
499    /// state where every read of the active buffer returns `None` and the
500    /// editor renders `<no buffer>` forever. Closing the last buffer opens a
501    /// scratch rather than emptying the set, which is what vim's `:bd` does
502    /// and what the type demands.
503    fn close_buffer(&mut self, id: BufferId) {
504        if self.buffers.close(id).is_none() {
505            self.messages.push("no such buffer".to_string());
506            return;
507        }
508        if self.active != id {
509            return;
510        }
511        // The active buffer went. Prefer the next one by id so repeated
512        // closes walk forward predictably rather than jumping around.
513        let next = self.buffers.ids().into_iter().find(|b| *b > id);
514        self.active = match next.or_else(|| self.buffers.ids().into_iter().next_back()) {
515            Some(b) => b,
516            None => self.buffers.scratch(""),
517        };
518        self.set_cursor(Position::ZERO);
519        if let Some(w) = self.layout.active_window_mut() {
520            w.buffer_id = self.active;
521        }
522    }
523
524    /// Move to the next or previous buffer, wrapping.
525    fn cycle_buffer(&mut self, forward: bool) {
526        let ids = self.buffers.ids();
527        if ids.len() < 2 {
528            self.messages.push("only one buffer".to_string());
529            return;
530        }
531        let at = ids.iter().position(|b| *b == self.active).unwrap_or(0);
532        let next = if forward {
533            (at + 1) % ids.len()
534        } else {
535            (at + ids.len() - 1) % ids.len()
536        };
537        self.active = ids[next];
538        self.set_cursor(Position::ZERO);
539        if let Some(w) = self.layout.active_window_mut() {
540            w.buffer_id = self.active;
541        }
542    }
543
544    /// Re-clamp the cursor and re-contain the viewport after a buffer
545    /// mutation.
546    ///
547    /// An undo can SHRINK the buffer under a cursor that was legal a moment
548    /// ago, leaving it out of bounds and its viewport scrolled past the end.
549    /// The Action executor has always done this (`self.set_cursor(self.cursor())`
550    /// after undo/redo/save); the M1 interpreter did NOT, so `u` re-followed
551    /// and `:undo` did not — two implementations of one operation, already
552    /// drifted within one milestone of being written. Naming it once is the
553    /// fix; lowering the Action arms onto the same slips is what keeps it
554    /// fixed.
555    fn refollow(&mut self) {
556        self.set_cursor(self.cursor());
557    }
558
559    /// Apply one slip and record what it damaged.
560    ///
561    /// The bookkeeping wrapper. The Action executor calls
562    /// [`honour_one`](Self::honour_one) directly because it does its own,
563    /// wider bookkeeping (the dot register, the S3 damage seal) around a
564    /// whole action.
565    fn honour(&mut self, slip: Negai) {
566        let touches_text = slip.touches_text();
567        self.honour_one(slip);
568        self.damage = self.damage.join(if touches_text {
569            Damage::Full
570        } else {
571            Damage::Viewport
572        });
573        self.bump_gen();
574    }
575
576    /// Apply one slip. THE single implementation of every mutation a slip
577    /// can ask for.
578    ///
579    /// Total over `Negai`: a new request variant is a compile error here
580    /// rather than a request silently ignored — the same failure Phase 0
581    /// removed one layer up.
582    fn honour_one(&mut self, slip: Negai) {
583        match slip {
584            Negai::Edit { buffer, edit } => {
585                if let Some(b) = self.buffers.get_mut(buffer) {
586                    let _ = b.apply(&edit);
587                }
588                self.refollow();
589            }
590            Negai::SetCursor { buffer, to } => {
591                // Clamping is the interpreter's job, exactly so that no
592                // handler has to re-implement it and get it wrong.
593                let clamped = self.buffers.get(buffer).map_or(to, |b| b.clamp(to));
594                self.set_cursor(clamped);
595            }
596            Negai::EnterMode(m) => self.modal.enter(m),
597            Negai::OpenPicker(source) => self.open_picker(source),
598            Negai::SplitWindow { stacked } => {
599                let axis = if stacked {
600                    escriba_ui::shikiri::Axis::Stacked
601                } else {
602                    escriba_ui::shikiri::Axis::SideBySide
603                };
604                self.layout.split_active(axis);
605                // The new pane is narrower/shorter than the old one, so the
606                // cursor can now be outside it. Every face re-reports its
607                // frame on the next draw, but the invariant must hold NOW —
608                // an operator who splits and immediately types should not be
609                // editing off-screen.
610                self.refollow_cursor();
611                self.damage = self.damage.join(Damage::Viewport);
612            }
613            Negai::CloseWindow => {
614                let id = self.layout.active();
615                if self.layout.close(id) {
616                    self.refollow_cursor();
617                    self.damage = self.damage.join(Damage::Viewport);
618                } else {
619                    // vim's E444, and the same refusal: the last window is
620                    // the editor. Closing it would mean "quit", which is a
621                    // different verb the operator did not type.
622                    self.messages
623                        .push("E444: Cannot close last window".to_string());
624                }
625            }
626            Negai::FocusDir { dx, dy } => {
627                use escriba_ui::Dir;
628                let dir = match (dx, dy) {
629                    (d, _) if d < 0 => Dir::Left,
630                    (d, _) if d > 0 => Dir::Right,
631                    (_, d) if d < 0 => Dir::Up,
632                    _ => Dir::Down,
633                };
634                if let Some(id) = self.layout.neighbour(dir) {
635                    self.layout.focus(id);
636                    // The window we moved to has its OWN buffer; the editor's
637                    // active buffer follows focus, or the next keystroke
638                    // would edit the file we just navigated away from.
639                    if let Some(w) = self.layout.active_window() {
640                        self.active = w.buffer_id;
641                    }
642                    self.refollow_cursor();
643                    self.damage = self.damage.join(Damage::Viewport);
644                }
645                // No neighbour is not an error — it is the edge of the
646                // layout, and vim says nothing there either.
647            }
648            Negai::GrepProject { pattern } => self.grep_project(&pattern),
649            Negai::CycleBuffer { forward } => self.cycle_buffer(forward),
650            Negai::FocusBuffer(id) => {
651                if self.buffers.get(id).is_some() {
652                    self.active = id;
653                }
654            }
655            Negai::OpenPath(path) => match self.buffers.open(&path) {
656                Ok(id) => self.active = id,
657                Err(e) => self.messages.push(e.to_string()),
658            },
659            Negai::CloseBuffer(id) => self.close_buffer(id),
660            Negai::Save { buffer } => {
661                if let Some(b) = self.buffers.get_mut(buffer) {
662                    if let Err(e) = b.save() {
663                        self.messages.push(e.to_string());
664                    }
665                }
666                self.refollow();
667            }
668            Negai::Undo { buffer } => {
669                if let Some(b) = self.buffers.get_mut(buffer) {
670                    let _ = b.undo();
671                }
672                self.refollow();
673            }
674            Negai::Redo { buffer } => {
675                if let Some(b) = self.buffers.get_mut(buffer) {
676                    let _ = b.redo();
677                }
678                self.refollow();
679            }
680            Negai::Yank { text, .. } => self.register = Some(text),
681            Negai::ClearSearchHighlight => self.search.clear_highlight(),
682            Negai::SetOption { name, value } => {
683                self.options.insert(name, value);
684            }
685            Negai::InsertText(text) => self.insert_text(&text),
686            Negai::RunCommand { name, args } => self.run_command(&name, &args),
687            Negai::PublishFindings { list, findings } => {
688                let world = self.world();
689                self.results
690                    .publish(list, escriba_shirube::ResultList::new(findings, world));
691            }
692            // A reply from off the tick. Honour it only if the world it was
693            // computed against still holds.
694            //
695            // The drop is silent BY DESIGN, and this is the one place in the
696            // slip vocabulary where silence is right: a stale reply is not a
697            // failure anyone can act on. The operator kept typing, which is
698            // the correct thing to have done, and telling them "a diagnostic
699            // you never asked for was discarded" is noise. The producer
700            // re-runs against the new world; that is the whole contract.
701            Negai::ErrandReply { anchor, then } => {
702                if anchor.is_fresh(&self.world()) {
703                    self.honour_one(*then);
704                }
705            }
706            Negai::WalkList { list, forward } => self.walk_list(&list, forward),
707            Negai::Message(m) => self.messages.push(m),
708            Negai::Quit => self.quit_requested = true,
709            // Both suspend the dispatch and need machinery that does not
710            // exist yet — the courier (Phase 5) and the AwaitKey resume
711            // (M3). Announced, never silently dropped: a slip that vanishes
712            // is the class Phase 0 sealed.
713            Negai::Errand(_) | Negai::AwaitKey { .. } => {
714                self.messages
715                    .push("deferred work is not wired yet".to_string());
716            }
717        }
718    }
719}
720
721/// Outcome of feeding one key to the multi-key pending-stroke loop.
722enum SeqStep {
723    /// Key consumed into an in-progress sequence; wait for the next.
724    Pending,
725    /// A full bound sequence resolved — run this action.
726    Resolved(Action),
727    /// Key is not part of any sequence; hand it to single-key dispatch.
728    Passthrough,
729}
730
731/// Keys whose HELD repeat is a viewport storm, and which the repeat gate
732/// therefore exists to debounce.
733///
734/// This is an ALLOW-LIST, and it used to be the complement — an exception list
735/// of "discrete" keys that grew three times (`n`/`N`/`*`/`#`, then `.`/`u`/
736/// `<C-r>`, then `/`/`?`/`:`), each time because a key had been silently
737/// swallowed and someone noticed. The third growth is the signal that the
738/// default was backwards: almost every key in a modal editor is a discrete,
739/// deliberate press, and only a handful are ones you HOLD.
740///
741/// Inverting it makes the failure mode safe. Forgetting to list a key here now
742/// means it is ungated — one extra keypress honoured — instead of silently
743/// dropped, and a dropped key is indistinguishable from a dead one.
744///
745/// Measured cost of the old direction: `/foo<CR>` then `/<CR>` (vim's
746/// reuse-the-previous-pattern) lost the second `/` outright, because the gate
747/// is keyed by KEY and the two presses fell inside one debounce window.
748const fn is_repeat_storm_candidate(key: &Key) -> bool {
749    matches!(
750        key,
751        // The four navigation keys a user actually holds down. `h`/`l` and
752        // `j`/`k` flood the motion path and thrash the viewport; everything
753        // else is pressed once and meant once.
754        Key::Char('h')
755            | Key::Char('j')
756            | Key::Char('k')
757            | Key::Char('l')
758            | Key::Left
759            | Key::Right
760            | Key::Up
761            | Key::Down
762    )
763}
764
765/// Turn a command failure into the sentence an operator should read.
766///
767/// The two failures mean genuinely different things and must not be reported
768/// the same way:
769///
770/// - `:flurb` — the operator typed a name that does not exist. "command not
771///   found" is exactly right; it says *you* made a typo.
772/// - `<leader>ff` bound to `picker.files` — escriba's OWN shipped config
773///   declares this, `--list-rc` counts it, and it is not built yet. Telling
774///   the operator "command not found" blames them for a gap we shipped.
775///
776/// The discriminator is the dotted form. `:action` takes action SYMBOLS
777/// (`picker.files`), never command names — that boundary is already pinned by
778/// `action_naming_a_command_is_inert_not_recursive` in escriba-command. So a
779/// dotted name that reached dispatch and resolved to nothing is a declared
780/// capability with no implementation, which is precisely what the 85 entries
781/// in `escriba/tests/action_resolution.rs` are.
782fn describe_command_failure(name: &str, e: &escriba_command::CommandError) -> String {
783    use escriba_command::CommandError as E;
784    match e {
785        // Already the right words — the registry knew it was declared.
786        E::Unhandled(_) => e.to_string(),
787        E::NotFound(n) if n.contains('.') => {
788            let mut m = String::with_capacity(n.len() + 48);
789            m.push('`');
790            m.push_str(n);
791            m.push_str("` is declared but not implemented yet");
792            m
793        }
794        _ => {
795            let _ = name;
796            e.to_string()
797        }
798    }
799}
800
801/// What committing the open search prompt did.
802///
803/// The two commit paths — bare `/` and operated `d/` — used to own private
804/// copies of the whole sequence (read origin+skip, `accept`, three-arm
805/// match, `commit_step_skipping`), and they drifted: the operated one
806/// never reported the wrap, so `d/foo<CR>` that wrapped the file was
807/// silent where `/foo<CR>` printed "search hit BOTTOM, continuing at TOP".
808///
809/// Total, and matched exhaustively at BOTH call sites, so a new outcome is
810/// a compile error in two places rather than a case one path quietly
811/// forgets. It does not make divergence impossible — the two paths
812/// genuinely differ at the landing step — it makes FORGETTING A CASE
813/// impossible, which is the failure that actually happened.
814enum CommitOutcome {
815    /// The prompt committed and a match was found.
816    Landed {
817        origin: usize,
818        step: escriba_search::Step,
819    },
820    /// Committed, but nothing matched. E486 already reported.
821    NotFound,
822    /// Nothing typed and no previous pattern. E35 already reported.
823    NoPrevious,
824    /// No prompt was open.
825    NoPrompt,
826}
827
828/// A replayable text change.
829#[derive(Debug, Clone)]
830struct LastChange {
831    /// The action that began the change.
832    action: Action,
833    /// How many times it ran.
834    count: u32,
835    /// Characters typed while the change held Insert mode open.
836    inserted: String,
837}
838
839impl EditorState {
840    /// Build a fresh editor with one buffer (scratch or file-backed).
841    pub fn new_with_buffer(initial: BufferSet, active: BufferId) -> Self {
842        let window = Window {
843            id: WindowId(1),
844            buffer_id: active,
845            viewport: Viewport {
846                top_line: 0,
847                left_column: 0,
848                visible_lines: 40,
849                visible_columns: 160,
850            },
851        };
852        Self {
853            buffers: initial,
854            modal: ModalState::new(),
855            search: SearchState::new(escriba_search::CaseMode::Smart),
856            search_at: None,
857            last_change: None,
858            recording_insert: false,
859            jumps: JumpList::new(),
860            keymap: Keymap::default_vim(),
861            commands: CommandRegistry::default_set(),
862            layout: Layout::single(window),
863            active,
864            cursors: Cursors::single(Position::ZERO),
865            quit_requested: false,
866            register: None,
867            op_pending: zenmai::Stateful::new(OpState::Resting),
868            pending_object: None,
869            messages: Vec::new(),
870            options: HashMap::new(),
871            lisp_vm: None,
872            pending_keys: Vec::new(),
873            repeat_gate: KeyRepeatGate::new(),
874            plugin_host: PluginHost::default(),
875            edit_gen: EditGen::default(),
876            damage: Damage::None,
877            // The FLEET default until an rc says otherwise — never a
878            // hand-written theme name, so a fleet re-point lands for free.
879            dispatch_depth: 0,
880            filetypes: escriba_core::FiletypeTable::new(),
881            results: escriba_shirube::ListRegistry::new(),
882            picker: None,
883            index_rev: escriba_shirube::IndexRev::default(),
884            session_gen: escriba_shirube::SessionGen::default(),
885            theme: FleetTheme::prescribed_default(),
886            chrome: ChromePalette::prescribed(),
887            splash: None,
888        }
889    }
890
891    /// The theme this editor is set to.
892    #[must_use]
893    pub const fn theme(&self) -> FleetTheme {
894        self.theme
895    }
896
897    /// The colours every face paints with — read once per frame.
898    #[must_use]
899    pub const fn chrome(&self) -> ChromePalette {
900        self.chrome
901    }
902
903    /// Point the editor at a theme. The wiring that makes
904    /// `(deftheme :preset …)` real.
905    ///
906    /// Bumps the refresh generation, because a theme change repaints
907    /// everything: the GPU face caches its shaped buffer against that
908    /// generation and would otherwise keep the old colours until an
909    /// unrelated edit happened to invalidate it.
910    pub fn set_theme(&mut self, theme: FleetTheme) {
911        if self.theme == theme {
912            return;
913        }
914        self.theme = theme;
915        self.chrome = ChromePalette::for_theme(theme);
916        self.damage = self.damage.join(Damage::Viewport);
917        self.bump_gen();
918    }
919
920    /// The start screen, if one is up. Renderers paint this INSTEAD of the
921    /// buffer pane; `None` is the ordinary editor.
922    #[must_use]
923    pub fn splash(&self) -> Option<&Splash> {
924        self.splash.as_ref()
925    }
926
927    /// Raise the start screen. The binary calls this at boot when no file
928    /// was named; an empty splash is refused so a face never has to render
929    /// a blank screen over a perfectly good buffer.
930    pub fn set_splash(&mut self, splash: Splash) {
931        if splash.is_empty() {
932            return;
933        }
934        self.splash = Some(splash);
935        self.damage = self.damage.join(Damage::Viewport);
936        self.bump_gen();
937    }
938
939    /// Take the start screen down. Idempotent; bumps the refresh generation
940    /// only when something actually changed, so dismissing twice does not
941    /// cost a repaint.
942    pub fn dismiss_splash(&mut self) {
943        if self.splash.take().is_some() {
944            self.damage = self.damage.join(Damage::Viewport);
945            self.bump_gen();
946        }
947    }
948
949    /// Offer `key` to the start screen.
950    ///
951    /// A menu key runs its entry; ANY other key simply takes the screen
952    /// down and is then handled normally — so the first thing an operator
953    /// types is never swallowed.
954    /// The open picker, for a face to paint.
955    #[must_use]
956    pub fn picker(&self) -> Option<&escriba_ui::picker::Picker> {
957        self.picker.as_ref()
958    }
959
960    /// Give an open picker the key.
961    ///
962    /// Runs BEFORE the keymap, and before the sequence stepper: while a
963    /// picker is up it owns every key, including ones it has no meaning for.
964    /// An overlay that let unknown keys fall through would edit the file
965    /// behind itself.
966    fn consume_picker_key(&mut self, key: &Key) -> escriba_ui::picker::Consumed {
967        use escriba_ui::picker::Consumed;
968        let Some(p) = self.picker.as_mut() else {
969            return Consumed::NotShowing;
970        };
971        let outcome = p.on_key(key);
972        match &outcome {
973            Consumed::Dismissed | Consumed::Chose(_) => {
974                self.picker = None;
975                self.bump_gen();
976            }
977            Consumed::Held => self.bump_gen(),
978            Consumed::NotShowing => {}
979        }
980        outcome
981    }
982
983    /// Lower an accepted pick into the ONE interpreter.
984    ///
985    /// The whole reason `Choice` is a closed enum: a new source must decide
986    /// here, and the compiler says so.
987    fn honour_choice(&mut self, choice: escriba_ui::picker::Choice) {
988        use escriba_ui::picker::Choice;
989        let slip = match choice {
990            Choice::Buffer(id) => Negai::FocusBuffer(id),
991            Choice::Command(name) => Negai::RunCommand {
992                name,
993                args: Vec::new(),
994            },
995            Choice::OpenFile(path) => Negai::OpenPath(path),
996            Choice::Location { path, line } => {
997                // Open FIRST, then jump: the buffer may not exist yet, and
998                // `jump_to_site` needs a BufferId. Two slips, one interpret.
999                self.interpret(Outcome::did(vec![Negai::OpenPath(path)]));
1000                let site = escriba_shirube::Site::in_buffer(
1001                    self.active,
1002                    escriba_core::Range::new(
1003                        escriba_core::Position::new(line, 0),
1004                        escriba_core::Position::new(line, 1),
1005                    ),
1006                );
1007                self.jump_to_site(&site);
1008                return;
1009            }
1010        };
1011        self.interpret(Outcome::did(vec![slip]));
1012    }
1013
1014    /// How many files a project grep will read, and how many hits it keeps.
1015    ///
1016    /// BOUNDED, and the bound is here rather than hidden, because this is a
1017    /// SYNCHRONOUS scan on the editor's own thread. The interpreter already
1018    /// does synchronous filesystem I/O (`OpenPath`, `Save`), so the posture
1019    /// is not new — but those touch one file and this walks a tree, which is
1020    /// the first one big enough to freeze the editor.
1021    ///
1022    /// The DESTINATION is the courier: an errand that scans off-thread and
1023    /// delivers results as they arrive, with no ceiling at all. This bound is
1024    /// the interim that ships a working grep meanwhile, and it is a real
1025    /// limit — a match past the ceiling is NOT found, and the picker says so
1026    /// rather than presenting a truncated list as complete.
1027    const GREP_FILE_LIMIT: usize = 2_000;
1028    const GREP_HIT_LIMIT: usize = 500;
1029
1030    /// Walk the working directory, bounded, returning `(files, truncated)`.
1031    ///
1032    /// ONE walker. grep, files and project each need to enumerate the tree,
1033    /// and three copies of a bounded traversal is three places to get the
1034    /// ceiling, the skip-list, or the truncation report subtly different.
1035    ///
1036    /// Skips dotfiles, `target` and `node_modules`. That is NOT a gitignore
1037    /// implementation and does not pretend to be — a real ignore crate comes
1038    /// with the courier.
1039    fn walk_project(limit: usize) -> (Vec<std::path::PathBuf>, bool) {
1040        Self::walk_from(std::path::Path::new("."), limit)
1041    }
1042
1043    /// The same bounded walk, from an explicit root.
1044    ///
1045    /// `walk_project` is this with `.` — one traversal, two callers, rather
1046    /// than a second copy for "browse from somewhere else".
1047    fn walk_from(root: &std::path::Path, limit: usize) -> (Vec<std::path::PathBuf>, bool) {
1048        let mut out = Vec::new();
1049        let mut truncated = false;
1050        let mut stack = vec![root.to_path_buf()];
1051        while let Some(dir) = stack.pop() {
1052            let Ok(entries) = std::fs::read_dir(&dir) else {
1053                continue;
1054            };
1055            for entry in entries.flatten() {
1056                let name = entry.file_name();
1057                let name = name.to_string_lossy();
1058                if name.starts_with('.') || name == "target" || name == "node_modules" {
1059                    continue;
1060                }
1061                let path = entry.path();
1062                if entry.file_type().is_ok_and(|t| t.is_dir()) {
1063                    stack.push(path);
1064                    continue;
1065                }
1066                if out.len() >= limit {
1067                    truncated = true;
1068                    return (out, truncated);
1069                }
1070                out.push(path);
1071            }
1072        }
1073        (out, truncated)
1074    }
1075
1076    /// Say plainly when a bounded scan stopped short.
1077    ///
1078    /// A truncated list presented as complete is the failure this codebase
1079    /// keeps finding in itself; it does not get to ship one.
1080    fn report_truncation(&mut self, truncated: bool) {
1081        if truncated {
1082            self.messages
1083                .push("scan stopped at the limit — results are INCOMPLETE".to_string());
1084        }
1085    }
1086
1087    /// Scan the working directory for `pattern`, bounded.
1088    fn grep_project(&mut self, pattern: &str) {
1089        use escriba_ui::picker::{Choice, Picker, PickerItem, Source};
1090        if pattern.is_empty() {
1091            self.messages.push("grep: empty pattern".to_string());
1092            return;
1093        }
1094        let (files, mut truncated) = Self::walk_project(Self::GREP_FILE_LIMIT);
1095        let mut items: Vec<PickerItem<Choice>> = Vec::new();
1096        'outer: for path in files {
1097            let Ok(text) = std::fs::read_to_string(&path) else {
1098                continue; // binary or unreadable — not an error worth reporting
1099            };
1100            for (n, line) in text.lines().enumerate() {
1101                if !line.contains(pattern) {
1102                    continue;
1103                }
1104                if items.len() >= Self::GREP_HIT_LIMIT {
1105                    truncated = true;
1106                    break 'outer;
1107                }
1108                let Ok(n) = u32::try_from(n) else { break };
1109                let mut label = String::with_capacity(80);
1110                label.push_str(&path.to_string_lossy());
1111                label.push(':');
1112                label.push_str(&(n + 1).to_string());
1113                label.push_str("  ");
1114                label.push_str(line.trim());
1115                items.push(PickerItem::new(
1116                    Choice::Location {
1117                        path: path.clone(),
1118                        line: n,
1119                    },
1120                    label,
1121                ));
1122            }
1123        }
1124        if items.is_empty() {
1125            let mut m = String::from("grep: no matches for ");
1126            m.push_str(pattern);
1127            self.messages.push(m);
1128            return;
1129        }
1130        if truncated {
1131            // Stated, never silent. A truncated list presented as complete is
1132            // the failure this whole codebase keeps finding.
1133            self.messages
1134                .push("grep: stopped at the scan limit — results are INCOMPLETE".to_string());
1135        }
1136        self.picker = Some(Picker::open(Source::Grep, items));
1137        self.bump_gen();
1138    }
1139
1140    /// Where accepting a finding should take the operator.
1141    ///
1142    /// Returns `None` for a finding whose site names neither a path nor a
1143    /// live buffer — that is a finding about nowhere, and offering it would
1144    /// give the operator a row that does nothing when pressed.
1145    fn finding_choice(&self, f: &escriba_shirube::Finding) -> Option<escriba_ui::picker::Choice> {
1146        use escriba_ui::picker::Choice;
1147        let line = f.site.range.start.line;
1148        if let Some(p) = &f.site.path {
1149            return Some(Choice::Location {
1150                path: p.clone(),
1151                line,
1152            });
1153        }
1154        let id = f.site.buffer?;
1155        let b = self.buffers.get(id)?;
1156        // A buffer WITH a path becomes a Location, so the line survives. A
1157        // scratch buffer has no path to name, so the best available answer
1158        // is the buffer itself — and the line is lost. Stated rather than
1159        // hidden: a `Choice` that carried a BufferId AND a line would be
1160        // the fix, and it belongs with the picker, not here.
1161        b.path.as_ref().map_or(Some(Choice::Buffer(id)), |p| {
1162            Some(Choice::Location {
1163                path: p.clone(),
1164                line,
1165            })
1166        })
1167    }
1168
1169    /// How a finding's location reads in a list row.
1170    fn finding_label(&self, f: &escriba_shirube::Finding) -> String {
1171        if let Some(p) = &f.site.path {
1172            return p.to_string_lossy().into_owned();
1173        }
1174        f.site
1175            .buffer
1176            .and_then(|id| self.buffers.get(id))
1177            .and_then(|b| b.path.as_ref())
1178            .map_or_else(
1179                || String::from("[scratch]"),
1180                |p| p.to_string_lossy().into_owned(),
1181            )
1182    }
1183
1184    /// Picker rows for every file under `root`.
1185    ///
1186    /// `picker.files` and `files.open-parent` differ only in the root, so
1187    /// they share this rather than carrying two copies of the same body —
1188    /// which is what they did for one commit, and what the line-count lint
1189    /// correctly complained about.
1190    fn file_items(
1191        &mut self,
1192        root: &std::path::Path,
1193    ) -> Vec<escriba_ui::picker::PickerItem<escriba_ui::picker::Choice>> {
1194        use escriba_ui::picker::{Choice, PickerItem};
1195        let (files, truncated) = Self::walk_from(root, Self::GREP_FILE_LIMIT);
1196        self.report_truncation(truncated);
1197        files
1198            .into_iter()
1199            .map(|p| {
1200                let label = p.to_string_lossy().into_owned();
1201                PickerItem::new(Choice::OpenFile(p), label)
1202            })
1203            .collect()
1204    }
1205
1206    /// Picker rows for the located findings the `trouble.*` verbs show.
1207    ///
1208    /// Freshness is asked of the registry, not assumed: `fresh` filters
1209    /// against the CURRENT world, so a list anchored to a revision the
1210    /// buffer has moved past contributes nothing rather than offering a
1211    /// line that has since shifted.
1212    fn finding_items(
1213        &self,
1214        workspace: bool,
1215    ) -> Vec<escriba_ui::picker::PickerItem<escriba_ui::picker::Choice>> {
1216        use escriba_ui::picker::PickerItem;
1217        let world = self.world();
1218        let active = Some(self.active);
1219        let mut items = Vec::new();
1220        for name in self.results.names() {
1221            let Some(list) = self.results.get(name) else {
1222                continue;
1223            };
1224            for f in list.fresh(&world) {
1225                // `trouble.document` narrows to the buffer in front of the
1226                // operator. A finding that names only a path is
1227                // workspace-scoped by construction — it has no buffer to be
1228                // "this" one.
1229                if !workspace && f.site.buffer != active {
1230                    continue;
1231                }
1232                let Some(choice) = self.finding_choice(f) else {
1233                    continue;
1234                };
1235                let line = f.site.range.start.line;
1236                let mut label = String::with_capacity(64);
1237                label.push_str(f.severity.label());
1238                label.push_str("  ");
1239                label.push_str(&self.finding_label(f));
1240                label.push(':');
1241                label.push_str(&(line + 1).to_string());
1242                label.push_str("  ");
1243                label.push_str(&f.message);
1244                items.push(PickerItem::new(choice, label));
1245            }
1246        }
1247        items
1248    }
1249
1250    /// Build and open a picker over `source`.
1251    fn open_picker(&mut self, source: escriba_madoguchi::PickerSource) {
1252        use escriba_ui::picker::{Choice, Picker, PickerItem, Source};
1253        let (src, items) = match source {
1254            escriba_madoguchi::PickerSource::Buffers => (
1255                Source::Buffers,
1256                self.buffers
1257                    .ids()
1258                    .into_iter()
1259                    .filter_map(|id| {
1260                        let b = self.buffers.get(id)?;
1261                        let label = b.path.as_ref().map_or_else(
1262                            || String::from("[scratch]"),
1263                            |p| p.to_string_lossy().into_owned(),
1264                        );
1265                        Some(PickerItem::new(Choice::Buffer(id), label))
1266                    })
1267                    .collect::<Vec<_>>(),
1268            ),
1269            escriba_madoguchi::PickerSource::Help => (
1270                Source::Help,
1271                self.keymap
1272                    .entries_sorted()
1273                    .into_iter()
1274                    .map(|(mode, key, b)| {
1275                        // "NORMAL  gd   goto definition" — searchable by key,
1276                        // by mode, or by what it does, because a reader
1277                        // arrives from any of the three.
1278                        let mut label = String::with_capacity(48);
1279                        label.push_str(mode.as_str());
1280                        label.push_str("  ");
1281                        // `{key:?}` because there is no shared key FORMATTER
1282                        // in the fleet — awase owns the chord vocabulary but
1283                        // escriba-keymap's `Key` has no Display. That gap
1284                        // belongs to the keymap consolidation, not here, and
1285                        // inventing a fourth spelling would make it worse.
1286                        label.push_str(&format!("{key:?}"));
1287                        label.push_str("  ");
1288                        label.push_str(&b.description);
1289                        // Accepting runs the binding's action if it names a
1290                        // command; a typed Action has no name to run, so it
1291                        // reports rather than pretending.
1292                        let choice = match &b.action {
1293                            escriba_core::Action::Command { name, .. } => {
1294                                Choice::Command(name.clone())
1295                            }
1296                            other => Choice::Command(format!("{other:?}")),
1297                        };
1298                        PickerItem::new(choice, label)
1299                    })
1300                    .collect::<Vec<_>>(),
1301            ),
1302            escriba_madoguchi::PickerSource::Files => {
1303                (Source::Files, self.file_items(std::path::Path::new(".")))
1304            }
1305            escriba_madoguchi::PickerSource::Project => {
1306                // A project root is a directory carrying a marker. Derived
1307                // from the SAME walk rather than a second traversal — the
1308                // markers are files, so the walker already visited them.
1309                const MARKERS: &[&str] = &[
1310                    "Cargo.toml",
1311                    "flake.nix",
1312                    "package.json",
1313                    "go.mod",
1314                    "pyproject.toml",
1315                ];
1316                let (files, truncated) = Self::walk_project(Self::GREP_FILE_LIMIT);
1317                self.report_truncation(truncated);
1318                let mut roots: Vec<std::path::PathBuf> = files
1319                    .into_iter()
1320                    .filter(|p| {
1321                        p.file_name()
1322                            .is_some_and(|n| MARKERS.contains(&n.to_string_lossy().as_ref()))
1323                    })
1324                    .filter_map(|p| p.parent().map(std::path::Path::to_path_buf))
1325                    .collect();
1326                roots.sort();
1327                roots.dedup();
1328                (
1329                    Source::Project,
1330                    roots
1331                        .into_iter()
1332                        .map(|p| {
1333                            let label = p.to_string_lossy().into_owned();
1334                            PickerItem::new(Choice::OpenFile(p), label)
1335                        })
1336                        .collect::<Vec<_>>(),
1337                )
1338            }
1339            escriba_madoguchi::PickerSource::Commands => (
1340                Source::Commands,
1341                self.commands
1342                    .names()
1343                    .into_iter()
1344                    .map(|n| PickerItem::new(Choice::Command(n.to_string()), n.to_string()))
1345                    .collect::<Vec<_>>(),
1346            ),
1347            escriba_madoguchi::PickerSource::FilesUnder(root) => {
1348                (Source::Files, self.file_items(&root))
1349            }
1350            escriba_madoguchi::PickerSource::Findings { workspace } => {
1351                (Source::Findings, self.finding_items(workspace))
1352            }
1353        };
1354        if items.is_empty() {
1355            self.messages.push("nothing to pick from".to_string());
1356            return;
1357        }
1358        self.picker = Some(Picker::open(src, items));
1359        self.bump_gen();
1360    }
1361
1362    /// Read one key as operator-pending object selection.
1363    ///
1364    /// Returns `None` when the key is nothing to do with objects, so the
1365    /// ordinary path runs untouched.
1366    fn consume_object_key(&mut self, key: Key) -> Option<ObjectKey> {
1367        use escriba_core::TextObject as O;
1368        let Key::Char(c) = key else {
1369            // Esc (or anything non-printable) abandons a half-typed object
1370            // rather than leaving the editor silently armed.
1371            if self.pending_object.take().is_some() {
1372                self.op_pending
1373                    .dispatch((Action::ChangeMode(Mode::Normal), 1));
1374                return Some(ObjectKey::Consumed);
1375            }
1376            return None;
1377        };
1378
1379        // Second key: it names the object.
1380        if let Some(around) = self.pending_object.take() {
1381            let object = match c {
1382                'w' => Some(O::Word { around }),
1383                // vim's `b` and `B` aliases for the bracket pairs, plus the
1384                // brackets themselves in both directions.
1385                '(' | ')' | 'b' => Some(O::Delimited {
1386                    open: '(',
1387                    close: ')',
1388                    around,
1389                }),
1390                '{' | '}' | 'B' => Some(O::Delimited {
1391                    open: '{',
1392                    close: '}',
1393                    around,
1394                }),
1395                '[' | ']' => Some(O::Delimited {
1396                    open: '[',
1397                    close: ']',
1398                    around,
1399                }),
1400                '<' | '>' => Some(O::Delimited {
1401                    open: '<',
1402                    close: '>',
1403                    around,
1404                }),
1405                // Quotes: `open == close`, which is what tells the resolver
1406                // not to count nesting.
1407                '"' => Some(O::Delimited {
1408                    open: '"',
1409                    close: '"',
1410                    around,
1411                }),
1412                '\'' => Some(O::Delimited {
1413                    open: '\'',
1414                    close: '\'',
1415                    around,
1416                }),
1417                '`' => Some(O::Delimited {
1418                    open: '`',
1419                    close: '`',
1420                    around,
1421                }),
1422                _ => None,
1423            };
1424            let OpState::Awaiting { op, count } = *self.op_pending.state() else {
1425                return Some(ObjectKey::Consumed);
1426            };
1427            // Disarm either way: an unknown object key cancels the operator,
1428            // it does not leave it armed for the next unrelated keystroke.
1429            self.op_pending
1430                .dispatch((Action::ChangeMode(Mode::Normal), 1));
1431            let Some(object) = object else {
1432                return Some(ObjectKey::Consumed);
1433            };
1434            let composed = Action::ApplyOperatorObject { op, object };
1435            // `2diw` applies the object twice. The caller runs it once, so
1436            // the extra repeats happen here.
1437            for _ in 1..count {
1438                self.apply(&composed);
1439            }
1440            return Some(ObjectKey::Compose(composed));
1441        }
1442
1443        // First key: `i` or `a` while an operator waits.
1444        if matches!(c, 'i' | 'a') && matches!(self.op_pending.state(), OpState::Awaiting { .. }) {
1445            self.pending_object = Some(c == 'a');
1446            return Some(ObjectKey::Consumed);
1447        }
1448        None
1449    }
1450
1451    fn consume_splash_key(&mut self, key: &Key) -> SplashKey {
1452        let Some(splash) = self.splash.as_ref() else {
1453            return SplashKey::NotShowing;
1454        };
1455        let chosen = match key {
1456            Key::Char(c) => splash.entry_for(*c).map(|e| e.action.clone()),
1457            _ => None,
1458        };
1459        self.dismiss_splash();
1460        chosen.map_or(SplashKey::Dismissed, SplashKey::Ran)
1461    }
1462
1463    /// The current refresh generation. A renderer caches its products against
1464    /// this; equality is the freshness test (an unchanged generation ⇒ the
1465    /// last frame is still valid, so skip the re-highlight + re-shape).
1466    #[must_use]
1467    pub fn edit_gen(&self) -> EditGen {
1468        self.edit_gen
1469    }
1470
1471    /// Advance the refresh generation (a mutation happened).
1472    fn bump_gen(&mut self) {
1473        self.edit_gen = self.edit_gen.next();
1474    }
1475
1476    /// The accumulated dirty region (read-only). See [`take_damage`](Self::take_damage).
1477    #[must_use]
1478    pub fn damage(&self) -> Damage {
1479        self.damage
1480    }
1481
1482    /// Drain the accumulated dirty region, resetting to [`Damage::None`]. The
1483    /// renderer calls this once per frame to learn what to repaint, then the
1484    /// accumulator restarts — so damage never double-counts across frames.
1485    pub fn take_damage(&mut self) -> Damage {
1486        std::mem::replace(&mut self.damage, Damage::None)
1487    }
1488
1489    /// The line count of the active buffer (0 if none) — used to compute the
1490    /// [`Damage`] scope of a mutation.
1491    fn active_line_count(&self) -> u32 {
1492        self.buffers
1493            .get(self.active)
1494            .map_or(0, escriba_buffer::Buffer::line_count)
1495    }
1496
1497    /// Register a lazy USER plugin: its escriba entry is deferred until
1498    /// one of its `triggers` fires. Bundled defaults do NOT go through
1499    /// here — they are applied eagerly at boot. Empty `triggers` means
1500    /// the plugin never lazily activates (the binary applies eager
1501    /// plugins directly).
1502    pub fn register_lazy_plugin(
1503        &mut self,
1504        name: impl Into<String>,
1505        triggers: Vec<LazyTrigger>,
1506        entry_src: impl Into<String>,
1507    ) {
1508        self.plugin_host.register(name, triggers, entry_src);
1509    }
1510
1511    /// Apply a plugin entry's escriba-lisp to live state — the same
1512    /// keymap / command / option apply paths a user rc uses. Options are
1513    /// applied before keybinds so a plugin that sets `mapleader` resolves
1514    /// `<leader>` correctly. Returns the count of commands + keybinds it
1515    /// registered (best-effort; a malformed entry is skipped, not fatal).
1516    fn apply_plugin_entry(&mut self, entry_src: &str) -> usize {
1517        let Ok(plan) = escriba_lisp::apply_source(entry_src) else {
1518            return 0;
1519        };
1520        let cmd = escriba_lisp::apply_plan_to_commands(&plan, &mut self.commands);
1521        escriba_lisp::apply_plan_to_options(&plan, &mut self.options);
1522        if let Some(value) = self.options.get("mapleader") {
1523            if let Some(key) = escriba_lisp::parse_leader_key(value) {
1524                self.keymap.set_leader(key);
1525            }
1526        }
1527        let km = escriba_lisp::apply_plan_to_keymap(&plan, &mut self.keymap);
1528        (cmd.registered + km.keybinds_applied) as usize
1529    }
1530
1531    /// Fire any lazy plugin gated on a `FileType` trigger for `filetype`.
1532    /// Returns the number of plugins activated. Call when a buffer of a
1533    /// known filetype is opened.
1534    pub fn activate_filetype_plugins(&mut self, filetype: &str) -> usize {
1535        let pending = self.plugin_host.pending_for_filetype(filetype);
1536        let n = pending.len();
1537        for src in pending {
1538            self.apply_plugin_entry(&src);
1539        }
1540        n
1541    }
1542
1543    /// Fire any lazy plugin gated on an `Event` trigger for `event`.
1544    /// Returns the number of plugins activated.
1545    pub fn activate_event_plugins(&mut self, event: &str) -> usize {
1546        let pending = self.plugin_host.pending_for_event(event);
1547        let n = pending.len();
1548        for src in pending {
1549            self.apply_plugin_entry(&src);
1550        }
1551        n
1552    }
1553
1554    /// Advance one frame's worth of state given a raw madori event.
1555    ///
1556    /// Key events pass through the [`KeyRepeatGate`] first (see
1557    /// [`Self::tick_at`]); everything else is handled directly.
1558    pub fn tick(&mut self, event: &AppEvent) {
1559        self.tick_at(event, Instant::now());
1560    }
1561
1562    /// [`Self::tick`] with an explicit timestamp for the key-repeat gate —
1563    /// lets tests drive the debounce window without depending on the
1564    /// wall clock.
1565    pub fn tick_at(&mut self, event: &AppEvent, now: Instant) {
1566        match translate_app_event(event) {
1567            InputOutcome::Key(k) => {
1568                if self.gate_key(&k, now) {
1569                    self.on_key(&k);
1570                }
1571            }
1572            InputOutcome::Resized { .. } => {
1573                // Damage only. Each face owns its own geometry: the GPU
1574                // backend derives the grid in `RenderCallback::resize`, and
1575                // the ratatui face reads its area every frame. This arm used
1576                // to write `Window.rect`, which nothing ever read — so the
1577                // resize path was already doing no real work, it just looked
1578                // like it was.
1579                self.damage = self.damage.join(Damage::Viewport);
1580                self.bump_gen();
1581            }
1582            InputOutcome::Quit => self.quit_requested = true,
1583            InputOutcome::Focus(_) | InputOutcome::None => {}
1584        }
1585    }
1586
1587    /// Decide whether `key` survives the key-repeat gate at time `now`.
1588    ///
1589    /// Returns `true` when the key should be processed, `false` when it is
1590    /// an OS key-repeat storm tick that should be dropped. Gating applies
1591    /// ONLY in the navigation modes (Normal / Visual / VisualLine) — those
1592    /// are where a held `j`/`l` floods the motion path and thrashes the
1593    /// viewport. Insert and Command modes pass every key through ungated,
1594    /// because there "hold a key to repeat the character" is the intended
1595    /// behavior, not a storm to suppress.
1596    fn gate_key(&mut self, key: &Key, now: Instant) -> bool {
1597        match self.modal.mode() {
1598            Mode::Normal | Mode::Visual | Mode::VisualLine => {
1599                // The gate exists for HELD keys that flood the motion path and
1600                // thrash the viewport (`j`, `l`). It is wrong for the discrete
1601                // jumps: two `n` presses 10 ms apart mean two matches, and
1602                // swallowing the second is indistinguishable from a dead key —
1603                // the exact symptom the gate was added to prevent elsewhere.
1604                if is_repeat_storm_candidate(key) {
1605                    return self.repeat_gate.try_pass_at(*key, now);
1606                }
1607                true
1608            }
1609            Mode::Insert | Mode::Command => true,
1610        }
1611    }
1612
1613    /// Dispatch a single key through the keymap + apply the resulting action.
1614    pub fn on_key(&mut self, key: &Key) {
1615        // An open picker owns EVERY key while it is up — before the splash,
1616        // before the sequence stepper, before the keymap.
1617        match self.consume_picker_key(key) {
1618            escriba_ui::picker::Consumed::NotShowing => {}
1619            escriba_ui::picker::Consumed::Held | escriba_ui::picker::Consumed::Dismissed => return,
1620            escriba_ui::picker::Consumed::Chose(c) => {
1621                self.honour_choice(c);
1622                return;
1623            }
1624        }
1625        // The start screen owns the first keypress and nothing after it.
1626        match self.consume_splash_key(key) {
1627            SplashKey::NotShowing | SplashKey::Dismissed => {}
1628            SplashKey::Ran(action) => {
1629                self.apply(&action);
1630                return;
1631            }
1632        }
1633        // Operator-pending OBJECT selection runs before EVERYTHING, because
1634        // `di(` must not be read as `d` then `i` (insert) then `(`.
1635        if let Some(action) = self.consume_object_key(*key) {
1636            match action {
1637                ObjectKey::Consumed => return,
1638                ObjectKey::Compose(a) => {
1639                    self.apply(&a);
1640                    return;
1641                }
1642            }
1643        }
1644        // Multi-key sequence resolution runs first: a key that begins or
1645        // continues a bound sequence (`<leader>ff`, `gg`) is held or
1646        // resolved here before the single-key path sees it.
1647        match self.step_sequence(key) {
1648            SeqStep::Pending => return,
1649            SeqStep::Resolved(action) => {
1650                let count = self.modal.pending_count().unwrap_or(1);
1651                self.modal.clear_count();
1652                for _ in 0..count {
1653                    self.apply(&action);
1654                    if self.quit_requested {
1655                        return;
1656                    }
1657                }
1658                return;
1659            }
1660            SeqStep::Passthrough => {}
1661        }
1662        let counted = self.keymap.dispatch(&self.modal, key);
1663        // Count prefixes accumulate into modal state.
1664        if matches!(counted.action, Action::Pending) {
1665            if let Key::Char(c) = key {
1666                if c.is_ascii_digit() {
1667                    let d = u32::from(*c as u8 - b'0');
1668                    self.modal.append_count(d);
1669                }
1670            }
1671            return;
1672        }
1673        // The count flows through the operator-pending FSM (apply_counted), which
1674        // owns repetition: a bare motion runs count× , an operator captures its
1675        // count, and an operated motion multiplies the two. No naive outer loop.
1676        self.apply_counted(&counted.action, counted.count);
1677        // After applying, reset pending count.
1678        self.modal.clear_count();
1679    }
1680
1681    /// Advance the multi-key pending-stroke state machine for `key`.
1682    ///
1683    /// Sequences only apply in normal / visual modes — insert and
1684    /// command modes treat keys as literal text. Rules:
1685    /// - Mid-sequence: extend the pending prefix. Exact match →
1686    ///   [`SeqStep::Resolved`]; still a live prefix → [`SeqStep::Pending`];
1687    ///   otherwise abort the sequence and re-process this key fresh.
1688    /// - Not mid-sequence: if `key` begins a bound sequence AND is not
1689    ///   itself a complete single binding (single bindings win, so no
1690    ///   chord timeout is needed) → start pending. Otherwise
1691    ///   [`SeqStep::Passthrough`] to the single-key dispatcher.
1692    fn step_sequence(&mut self, key: &Key) -> SeqStep {
1693        let mode = self.modal.mode();
1694        if !matches!(mode, Mode::Normal | Mode::Visual | Mode::VisualLine) {
1695            return SeqStep::Passthrough;
1696        }
1697        if !self.pending_keys.is_empty() {
1698            let mut seq = self.pending_keys.clone();
1699            seq.push(key.clone());
1700            if let Some(b) = self.keymap.lookup_sequence(mode, &seq) {
1701                let action = b.action.clone();
1702                self.pending_keys.clear();
1703                return SeqStep::Resolved(action);
1704            }
1705            if self.keymap.is_sequence_prefix(mode, &seq) {
1706                self.pending_keys = seq;
1707                return SeqStep::Pending;
1708            }
1709            // The key broke the in-progress sequence — abort it and let
1710            // the key be re-processed as a fresh stroke below.
1711            self.pending_keys.clear();
1712        }
1713        let start = [key.clone()];
1714        if self.keymap.is_sequence_prefix(mode, &start) && self.keymap.lookup(mode, key).is_none() {
1715            self.pending_keys = start.to_vec();
1716            return SeqStep::Pending;
1717        }
1718        SeqStep::Passthrough
1719    }
1720
1721    /// The primary cursor position. The single read accessor — every
1722    /// renderer + motion path goes through it, so the underlying
1723    /// representation (today a single-cursor [`Cursors`]) can grow to
1724    /// multi-caret without changing read sites.
1725    #[must_use]
1726    pub fn cursor(&self) -> Position {
1727        self.cursors.primary()
1728    }
1729
1730    /// The **single** cursor-mutation path. Clamp the requested position to
1731    /// the active buffer's bounds, then scroll the active window's viewport
1732    /// to contain it on BOTH axes. Routing every cursor change through this
1733    /// (and through [`Cursors::set_primary`]) makes "cursor outside its
1734    /// viewport" an unrepresentable state, AND keeps cursor state in ONE
1735    /// typed home — there is no code path that advances the cursor without
1736    /// re-deriving the viewport from it, and no second `Position` field to
1737    /// fall out of sync.
1738    /// Re-assert the cursor-visibility invariant against the CURRENT
1739    /// viewport.
1740    ///
1741    /// A resize changes how much a face can show without moving the cursor,
1742    /// so nothing would otherwise re-run `scroll_to_contain` — the cursor
1743    /// would sit off-screen until the operator happened to move it. Every
1744    /// face calls this after telling the runtime its new size.
1745    pub fn refollow_cursor(&mut self) {
1746        self.set_cursor(self.cursors.primary());
1747    }
1748
1749    fn set_cursor(&mut self, pos: Position) {
1750        let clamped = if let Some(buf) = self.buffers.get(self.active) {
1751            buf.clamp(pos)
1752        } else {
1753            pos
1754        };
1755        self.cursors.set_primary(clamped);
1756        if let Some(w) = self.layout.active_window_mut() {
1757            w.viewport = w.viewport.scroll_to_contain(self.cursors.primary(), 2);
1758        }
1759    }
1760
1761    /// Dispatch one resolved action at count 1. See [`apply_counted`](Self::apply_counted).
1762    fn apply(&mut self, action: &Action) {
1763        self.apply_counted(action, 1);
1764    }
1765
1766    /// Dispatch one resolved action with its count. Routes `(action, count)`
1767    /// through the operator-pending FSM ([`OperatorPending`], on `zenmai`): most
1768    /// actions pass straight to [`apply_resolved`](Self::apply_resolved) carrying
1769    /// their count (so `5j` runs the motion 5×), an operator key is held, and an
1770    /// operator-then-motion pair is rewritten into a counted
1771    /// [`Action::ApplyOperator`] (so `3dw` deletes 3 words). The FSM owns count
1772    /// composition — there is no naive outer repeat loop.
1773    fn apply_counted(&mut self, action: &Action, count: u32) {
1774        // An uncompilable pattern must not reach the operator machine.
1775        //
1776        // `SearchState::accept` puts the prompt BACK on a compile error so the
1777        // typed text is not lost — but the FSM had already transitioned out of
1778        // `AwaitingSearch` on the way in, so the prompt survived and the
1779        // OPERATOR did not, with nothing said about it. The `d` was simply
1780        // gone, and the corrected pattern then ran as a bare search.
1781        //
1782        // The machine is a pure `(State, Event) -> (State, effects)` and
1783        // cannot observe the result of an effect, so it cannot decide this
1784        // itself. The fix is to stop handing it an event it has no business
1785        // deciding: the runtime classifies the submit first, from state it
1786        // already holds. `prompt_error` returns `None` for an EMPTY prompt, so
1787        // the bare-`/<CR>` reuse path is untouched.
1788        //
1789        // Tier-honest: parse-rejected at the boundary, not
1790        // truly-unrepresentable.
1791        if matches!(action, Action::SubmitCommand) {
1792            if let Some(e) = self.search.prompt_error() {
1793                let mut m = String::from("E383: Invalid search string: ");
1794                m.push_str(&e.to_string());
1795                self.messages.push(m);
1796                return;
1797            }
1798        }
1799
1800        for (resolved, times) in self.op_pending.dispatch((action.clone(), count)) {
1801            // `3dw` is ONE delete of three words as far as the register is
1802            // concerned, not three deletes of one. Each repetition emits its
1803            // own `Negai::Yank`, and each used to overwrite the register — so
1804            // `3dwP` put back only the third word and silently lost two.
1805            //
1806            // Repetitions of a REGISTER-LEAVING operator therefore append,
1807            // and the flag is cleared after the group so an unrelated later
1808            // yank still replaces rather than growing forever.
1809            // A COUNTED operator-over-motion is ONE operation over a motion
1810            // resolved `times` over, not `times` operations over one motion.
1811            //
1812            // That distinction is not pedantry. Repeating the operation works
1813            // by accident for delete — the text vanishes, so the cursor ends
1814            // up somewhere new each round — and is simply wrong for yank,
1815            // which does not move the cursor: `2yw` re-yanked the FIRST word
1816            // twice and put "one one " in the register. Resolving the motion
1817            // twice and yanking once gives "one two ", and the register needs
1818            // no accumulation because there was only ever one yank.
1819            if let Action::ApplyOperator { op, motion } = resolved {
1820                self.apply_operator_n(op, motion, times);
1821                if self.quit_requested {
1822                    return;
1823                }
1824                continue;
1825            }
1826            for _ in 0..times {
1827                self.apply_resolved(&resolved);
1828                if self.quit_requested {
1829                    return;
1830                }
1831            }
1832        }
1833    }
1834
1835    /// The active buffer's text. Search is a pure function of it.
1836    /// The active buffer's text revision — the token an offset measured
1837    /// against it should carry.
1838    #[must_use]
1839    fn text_rev(&self) -> TextRev {
1840        self.buffers
1841            .get(self.active)
1842            .map_or_else(TextRev::default, escriba_buffer::Buffer::text_rev)
1843    }
1844
1845    fn active_text(&self) -> String {
1846        self.buffers
1847            .get(self.active)
1848            .map(escriba_buffer::Buffer::to_string)
1849            .unwrap_or_default()
1850    }
1851
1852    /// The cursor as a char offset — the coordinate search speaks.
1853    fn cursor_char(&self) -> usize {
1854        self.buffers
1855            .get(self.active)
1856            .and_then(|b| b.position_to_char(self.cursor()).ok())
1857            .unwrap_or(0)
1858    }
1859
1860    /// Move the cursor onto a match and report a wrap the way vim does.
1861    /// The status line as data — what every face draws.
1862    ///
1863    /// One model, so the two faces can only disagree about styling. Before
1864    /// this existed the GPU face built its own line from a fixed `format!()`
1865    /// and drew neither the prompt nor any message, which made a fully
1866    /// working `/` look like a dead key on escriba's default renderer.
1867    #[must_use]
1868    pub fn status_model(&self) -> StatusModel<'_> {
1869        let cursor = self.cursor();
1870        let prompt = self.search.prompt();
1871
1872        let kind = match prompt.map(|p| p.direction) {
1873            Some(escriba_search::Direction::Forward) => PromptKind::SearchForward,
1874            Some(escriba_search::Direction::Backward) => PromptKind::SearchBackward,
1875            // Command mode with no search prompt open is an ex-command; the
1876            // typed `Option<Prompt>` is the discriminator, never a mode flag.
1877            None if self.modal.mode() == Mode::Command => PromptKind::Ex,
1878            None => PromptKind::None,
1879        };
1880
1881        StatusModel {
1882            mode: self.modal.mode(),
1883            line: cursor.line.saturating_add(1) as usize,
1884            column: cursor.column.saturating_add(1) as usize,
1885            prompt: kind,
1886            prompt_text: prompt
1887                .map_or_else(|| self.modal.minibuffer(), escriba_search::Prompt::text),
1888            prompt_caret: prompt.map_or_else(
1889                || self.modal.minibuffer_caret(),
1890                escriba_search::Prompt::caret,
1891            ),
1892            count: self.match_count(),
1893            message: self.messages.last().map(String::as_str),
1894        }
1895    }
1896
1897    /// `[3/17]` for the current pattern.
1898    ///
1899    /// While a prompt is open the count describes the PREVIEW — the answer to
1900    /// "what would Enter do", which is the question being asked mid-typing.
1901    /// Once committed it describes where the cursor actually is.
1902    #[must_use]
1903    fn match_count(&self) -> MatchCount {
1904        if self.search.is_prompting() {
1905            let text = self.active_text();
1906            // ONE scan, four outcomes. `Incomplete` and `NoMatch` used to be
1907            // the same `None`, so a half-typed character class reported
1908            // `[0/0]` — telling the user their pattern matches nothing while
1909            // they are still writing it.
1910            return match self.search.preview(&text) {
1911                escriba_search::Preview::Landed { step, total } => {
1912                    MatchCount::new(step.index, total)
1913                }
1914                escriba_search::Preview::NoMatch => MatchCount::None,
1915                escriba_search::Preview::Incomplete | escriba_search::Preview::Idle => {
1916                    MatchCount::Idle
1917                }
1918            };
1919        }
1920        if self.search.pattern().is_none() {
1921            return MatchCount::Idle;
1922        }
1923        let total = self.search.matches().len();
1924        // Read THROUGH the anchor: an ordinal computed against text that has
1925        // since changed reads as absent, so a stale count cannot be displayed.
1926        let rev = self.text_rev();
1927        self.search_at.as_ref().and_then(|a| a.get(rev)).map_or(
1928            if total == 0 {
1929                MatchCount::None
1930            } else {
1931                MatchCount::Idle
1932            },
1933            |&i| MatchCount::new(i, total),
1934        )
1935    }
1936
1937    /// `.` — replay the last change at the cursor.
1938    ///
1939    /// Two steps, because a change can be two: run the action, then re-type
1940    /// whatever followed it. `cgn` + `.` is exactly this — change the next
1941    /// match, then repeat that whole gesture on the one after.
1942    fn repeat_last_change(&mut self) {
1943        let Some(change) = self.last_change.clone() else {
1944            self.messages
1945                .push("E32: No previous change to repeat".to_string());
1946            return;
1947        };
1948
1949        for _ in 0..change.count.max(1) {
1950            self.apply_resolved(&change.action);
1951        }
1952        for c in change.inserted.chars() {
1953            self.apply_resolved(&Action::InsertChar(c));
1954        }
1955        if self.modal.mode() == Mode::Insert {
1956            // A replayed change must not leave the editor in Insert — the
1957            // original ended with an Esc the recording deliberately does not
1958            // store, since it is punctuation rather than part of the change.
1959            self.apply_resolved(&Action::ChangeMode(Mode::Normal));
1960        }
1961        // The replay wrote through `apply_resolved`, which re-records
1962        // `last_change` from the inner action. Put the ORIGINAL back so a
1963        // second `.` repeats the same change rather than a fragment of it.
1964        self.last_change = Some(change);
1965        self.recording_insert = false;
1966    }
1967
1968    /// Resolve a text object to the range it names.
1969    ///
1970    /// `gn` uses the INCLUSIVE step, so a cursor already sitting inside a
1971    /// match operates on THAT match rather than skipping to the next — which
1972    /// is what makes `cgn` then `.` walk matches one at a time instead of
1973    /// every other one.
1974    /// `dd` — the current line INCLUDING its terminator.
1975    ///
1976    /// Taking the newline is what makes `dd` remove a line rather than blank
1977    /// it. On the last line there is no following newline to take, so it
1978    /// falls back to the preceding one — otherwise `dd` on the final line
1979    /// leaves an empty line behind, which is the one case a naive
1980    /// "start-of-line to start-of-next-line" range gets wrong.
1981    fn object_line(&self) -> Option<Range> {
1982        let buf = self.buffers.get(self.active)?;
1983        let line = self.cursor().line;
1984        let last = buf.line_count().saturating_sub(1);
1985        if line < last {
1986            Some(Range::new(
1987                Position::new(line, 0),
1988                Position::new(line + 1, 0),
1989            ))
1990        } else if line > 0 {
1991            // Final line: swallow the PRECEDING newline instead.
1992            Some(Range::new(
1993                Position::new(line - 1, buf.line_len_chars(line - 1)),
1994                Position::new(line, buf.line_len_chars(line)),
1995            ))
1996        } else {
1997            // The only line in the buffer: clear it, keep the line itself.
1998            Some(Range::new(
1999                Position::new(0, 0),
2000                Position::new(0, buf.line_len_chars(0)),
2001            ))
2002        }
2003    }
2004
2005    /// `iw` / `aw` — the word under the cursor.
2006    ///
2007    /// vim's `w` classes are word / punctuation / whitespace, and a text
2008    /// object never crosses a line. `around` additionally takes the trailing
2009    /// whitespace run, falling back to LEADING whitespace when there is none
2010    /// after — which is what vim does at end of line.
2011    fn object_word(&self, around: bool) -> Option<Range> {
2012        let buf = self.buffers.get(self.active)?;
2013        let pos = self.cursor();
2014        let text: Vec<char> = buf.line(pos.line)?.chars().collect();
2015        if text.is_empty() {
2016            return None;
2017        }
2018        let col = (pos.column as usize).min(text.len().saturating_sub(1));
2019
2020        #[derive(PartialEq, Clone, Copy)]
2021        enum Class {
2022            Word,
2023            Punct,
2024            Space,
2025        }
2026        let class = |c: char| {
2027            if c.is_alphanumeric() || c == '_' {
2028                Class::Word
2029            } else if c.is_whitespace() {
2030                Class::Space
2031            } else {
2032                Class::Punct
2033            }
2034        };
2035
2036        let here = class(text[col]);
2037        let mut start = col;
2038        while start > 0 && class(text[start - 1]) == here {
2039            start -= 1;
2040        }
2041        let mut end = col + 1;
2042        while end < text.len() && class(text[end]) == here {
2043            end += 1;
2044        }
2045
2046        if around {
2047            let after = end;
2048            while end < text.len() && class(text[end]) == Class::Space {
2049                end += 1;
2050            }
2051            // No trailing run: take the leading one instead, as vim does.
2052            if end == after {
2053                while start > 0 && class(text[start - 1]) == Class::Space {
2054                    start -= 1;
2055                }
2056            }
2057        }
2058
2059        Some(Range::new(
2060            Position::new(pos.line, start as u32),
2061            Position::new(pos.line, end as u32),
2062        ))
2063    }
2064
2065    /// `i(` / `a"` … — the region between a matched pair, on one line.
2066    ///
2067    /// Brackets NEST and quotes do not, and that is the only difference:
2068    /// with `open == close` the scan cannot count depth, so it takes the
2069    /// nearest delimiter on each side instead.
2070    fn object_delimited(&self, open: char, close: char, around: bool) -> Option<Range> {
2071        let buf = self.buffers.get(self.active)?;
2072        let pos = self.cursor();
2073        let text: Vec<char> = buf.line(pos.line)?.chars().collect();
2074        if text.is_empty() {
2075            return None;
2076        }
2077        let col = (pos.column as usize).min(text.len().saturating_sub(1));
2078
2079        let (l, r) = if open == close {
2080            // Quotes: nearest on each side, no nesting to track.
2081            let l = (0..=col).rev().find(|&i| text[i] == open)?;
2082            let r = ((col.max(l) + 1)..text.len()).find(|&i| text[i] == close)?;
2083            (l, r)
2084        } else {
2085            // Brackets: walk out counting depth, so an inner pair does not
2086            // terminate the search for the enclosing one.
2087            let mut depth = 0i32;
2088            let l = (0..=col).rev().find(|&i| {
2089                if text[i] == close && i != col {
2090                    depth += 1;
2091                    false
2092                } else if text[i] == open {
2093                    if depth == 0 {
2094                        true
2095                    } else {
2096                        depth -= 1;
2097                        false
2098                    }
2099                } else {
2100                    false
2101                }
2102            })?;
2103            depth = 0;
2104            let r = ((l + 1)..text.len()).find(|&i| {
2105                if text[i] == open {
2106                    depth += 1;
2107                    false
2108                } else if text[i] == close {
2109                    if depth == 0 {
2110                        true
2111                    } else {
2112                        depth -= 1;
2113                        false
2114                    }
2115                } else {
2116                    false
2117                }
2118            })?;
2119            (l, r)
2120        };
2121
2122        // `i` is strictly between the delimiters; `a` includes them.
2123        let (s, e) = if around { (l, r + 1) } else { (l + 1, r) };
2124        Some(Range::new(
2125            Position::new(pos.line, s as u32),
2126            Position::new(pos.line, e as u32),
2127        ))
2128    }
2129
2130    fn resolve_object(&self, object: escriba_core::TextObject) -> Option<Range> {
2131        use escriba_core::TextObject as O;
2132
2133        // The text-scanning objects resolve against the BUFFER; the two
2134        // search objects resolve against the match set. Splitting here keeps
2135        // the search logic below exactly as it was rather than threading a
2136        // second concern through it.
2137        match object {
2138            O::Line => return self.object_line(),
2139            O::Word { around } => return self.object_word(around),
2140            O::Delimited {
2141                open,
2142                close,
2143                around,
2144            } => return self.object_delimited(open, close, around),
2145            O::NextMatch | O::PrevMatch => {}
2146        }
2147
2148        let at = self.cursor_char();
2149        let matches = self.search.matches();
2150
2151        // A match CONTAINING the cursor wins outright, whichever direction the
2152        // object names.
2153        //
2154        // Comparing only against `m.start` — which is what a `starts`-vector
2155        // plus `Bound::Inclusive` does — is right only when the cursor sits on
2156        // a match's FIRST character. One column further in, `start < at` and
2157        // the match is rejected, so `cgn` skipped the very instance the
2158        // operator was standing in and the rename silently missed it. vim
2159        // operates on the containing match from every interior column, and the
2160        // `starts`-only comparison cannot express "contains" because it never
2161        // looks at `m.end`.
2162        let idx = matches.iter().position(|m| m.contains(at)).or_else(|| {
2163            let starts: Vec<usize> = matches.iter().map(|m| m.start).collect();
2164            match object {
2165                O::PrevMatch => Bound::Inclusive.first_matching(&starts, at, false),
2166                // Every other variant returned above; `NextMatch` is the only
2167                // one that can reach here besides `PrevMatch`.
2168                _ => Bound::Inclusive.first_matching(&starts, at, true),
2169            }
2170        })?;
2171
2172        let m = matches.get(idx)?;
2173        let buf = self.buffers.get(self.active)?;
2174        Some(Range {
2175            start: buf.char_to_position(m.start),
2176            end: buf.char_to_position(m.end),
2177        })
2178    }
2179
2180    fn land_on(&mut self, step: escriba_search::Step) {
2181        if let Some(buf) = self.buffers.get(self.active) {
2182            let pos = buf.char_to_position(step.target.start);
2183            self.set_cursor(pos);
2184        }
2185        // The `[3/17]` numerator. `Step` has carried this index since the
2186        // engine was written — `engine.rs` even names the counter as the
2187        // reason it exists — and every consumer discarded it until now.
2188        self.search_at = Some(Anchored::new(step.index, self.text_rev()));
2189    }
2190
2191    /// vim's "search hit BOTTOM, continuing at TOP".
2192    ///
2193    /// One reporter, called by the two places a search can wrap: the shared
2194    /// commit and `n`/`N`. `land_on` deliberately does NOT report, or the bare
2195    /// commit would say it twice.
2196    fn report_wrap(&mut self, step: &escriba_search::Step) {
2197        if let Some(msg) = escriba_search::wrap_message(step.wrapped) {
2198            self.messages.push(msg.to_string());
2199        }
2200    }
2201
2202    /// `n` / `N`. Reports vim's E486 when the pattern matches nothing, rather
2203    /// than failing silently — a search that appears to do nothing is
2204    /// indistinguishable from a dropped keystroke.
2205    fn jump_search(&mut self, reverse: bool) {
2206        // Using the matches re-lights them: `n` after an auto-clear shows you
2207        // what you are walking through.
2208        self.search.relight();
2209        // `n` is a far jump — record where we leave from so `<C-o>` works.
2210        self.jumps.push(self.spot());
2211        let at = self.cursor_char();
2212        match self.search.repeat(at, reverse) {
2213            Some(step) => {
2214                // `n` wrapping the file says so, same as a commit does.
2215                self.report_wrap(&step);
2216                self.land_on(step);
2217            }
2218            None => {
2219                let msg = self.search.pattern().map_or_else(
2220                    || "E35: No previous regular expression".to_string(),
2221                    |p| {
2222                        let mut m = String::from("E486: Pattern not found: ");
2223                        m.push_str(p.raw());
2224                        m
2225                    },
2226                );
2227                self.messages.push(msg);
2228            }
2229        }
2230    }
2231
2232    /// Move the cursor to where the in-progress pattern would land, without
2233    /// committing anything. vim's `incsearch`.
2234    ///
2235    /// A pattern that does not compile yet (`/a[`, mid-typing) previews
2236    /// nothing and reports nothing — an error toast on every keystroke of a
2237    /// character class would be unusable.
2238    fn preview_search(&mut self) {
2239        let text = self.active_text();
2240        let Some(origin) = self.search.prompt().map(|p| p.origin) else {
2241            return;
2242        };
2243        let target = match self.search.preview(&text) {
2244            escriba_search::Preview::Landed { step, .. } => step.target.start,
2245            // Nothing to show: back to where the search started. Covers a
2246            // half-typed pattern and a pattern that finds nothing alike —
2247            // both mean "there is no match to preview".
2248            escriba_search::Preview::Idle
2249            | escriba_search::Preview::Incomplete
2250            | escriba_search::Preview::NoMatch => origin,
2251        };
2252        // A pattern that STOPS matching returns the cursor to the origin.
2253        //
2254        // Preview used to only ever move forward, so typing `ch` (a match) and
2255        // then `chz` (none) left the cursor parked on the `ch` match — a
2256        // preview showing a position the pattern no longer justifies, while
2257        // the count beside it read `[0/0]`. Restoring is also what makes
2258        // Escape's promise legible: at every keystroke the cursor is either on
2259        // a real match or back where you started, never on a stale one.
2260        if let Some(buf) = self.buffers.get(self.active) {
2261            let pos = buf.char_to_position(target);
2262            self.set_cursor(pos);
2263        }
2264    }
2265
2266    /// `d/foo<CR>` — commit the prompt and operate from the prompt's origin to
2267    /// where the search lands, as ONE action.
2268    ///
2269    /// Split from [`Self::submit_search`] rather than sharing it because the
2270    /// two want opposite things from the commit: the bare `/` MOVES the cursor
2271    /// to the match, and an operated `/` must NOT — the cursor is the
2272    /// operator's start point, and moving it first would leave the operator
2273    /// with a zero-width range.
2274    /// Commit the open search prompt. The ONE copy of the sequence.
2275    ///
2276    /// Reports its own failures (E486 / E35) so neither caller has to carry a
2277    /// third copy of the message strings. `Accepted::Invalid` cannot reach
2278    /// here — `apply_counted` rejects an uncompilable pattern at the dispatch
2279    /// boundary before the FSM or this method ever sees the submit.
2280    fn commit_search_prompt(&mut self) -> CommitOutcome {
2281        let text = self.active_text();
2282        let Some((origin, skip)) = self.search.prompt().map(|p| (p.origin, p.preview_skip()))
2283        else {
2284            return CommitOutcome::NoPrompt;
2285        };
2286
2287        match self.search.accept(&text) {
2288            escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
2289                self.modal.clear_minibuffer();
2290                self.modal.enter(Mode::Normal);
2291                match self.search.commit_step_skipping(origin, skip) {
2292                    Some(step) => {
2293                        // The wrap notice belongs HERE, once, for both commit
2294                        // paths. Reporting it in each caller is what let the
2295                        // operated path lose it in the first place — and my
2296                        // first attempt at this refactor duplicated it again
2297                        // rather than moving it, which the red proof caught.
2298                        self.report_wrap(&step);
2299                        CommitOutcome::Landed { origin, step }
2300                    }
2301                    None => {
2302                        self.report_pattern_not_found();
2303                        CommitOutcome::NotFound
2304                    }
2305                }
2306            }
2307            escriba_search::Accepted::NothingToRepeat => {
2308                self.modal.clear_minibuffer();
2309                self.modal.enter(Mode::Normal);
2310                self.messages
2311                    .push("E35: No previous regular expression".to_string());
2312                CommitOutcome::NoPrevious
2313            }
2314            // Unreachable: the boundary guard in `apply_counted` returns early
2315            // on an uncompilable pattern, leaving the prompt open. Reported
2316            // rather than `unreachable!()` — a panic in the editor's commit
2317            // path is a worse failure than a duplicate message.
2318            escriba_search::Accepted::Invalid(e) => {
2319                let mut m = String::from("E383: Invalid search string: ");
2320                m.push_str(&e.to_string());
2321                self.messages.push(m);
2322                CommitOutcome::NoPrompt
2323            }
2324        }
2325    }
2326
2327    /// vim's E486, with the pattern named. One place, so every path that fails
2328    /// to find reports identically.
2329    fn report_pattern_not_found(&mut self) {
2330        let mut m = String::from("E486: Pattern not found");
2331        if let Some(p) = self.search.pattern() {
2332            m.push_str(": ");
2333            m.push_str(p.raw());
2334        }
2335        self.messages.push(m);
2336    }
2337
2338    /// Bare `/foo<CR>` — commit and MOVE the cursor to the match.
2339    ///
2340    /// The only difference from the operated path is that this one lands;
2341    /// everything else lives in `commit_search_prompt`.
2342    fn submit_search(&mut self) {
2343        match self.commit_search_prompt() {
2344            CommitOutcome::Landed { origin, step } => {
2345                if let Some(buf) = self.buffers.get(self.active) {
2346                    let from = buf.char_to_position(origin);
2347                    self.jumps.push(escriba_core::Spot::new(self.active, from));
2348                }
2349                self.land_on(step);
2350            }
2351            CommitOutcome::NotFound | CommitOutcome::NoPrevious | CommitOutcome::NoPrompt => {}
2352        }
2353    }
2354
2355    /// `d/foo<CR>` — commit, then operate from the prompt's origin to where the
2356    /// search lands, as ONE action.
2357    ///
2358    /// The cursor must NOT move to the match first: it is the operator's start
2359    /// point. That is the whole reason this differs from the bare path, and
2360    /// now the only reason.
2361    fn submit_search_operated(&mut self, op: Operator) {
2362        match self.commit_search_prompt() {
2363            CommitOutcome::Landed { origin, step } => {
2364                if let Some(buf) = self.buffers.get(self.active) {
2365                    let from = buf.char_to_position(origin);
2366                    let target = buf.char_to_position(step.target.start);
2367                    // Operating over a search is itself a far jump.
2368                    self.jumps.push(escriba_core::Spot::new(self.active, from));
2369                    self.set_cursor(from);
2370                    self.apply_operator_to(op, target);
2371                }
2372            }
2373            CommitOutcome::NotFound | CommitOutcome::NoPrevious | CommitOutcome::NoPrompt => {}
2374        }
2375    }
2376
2377    fn apply_resolved(&mut self, action: &Action) {
2378        // Snapshot the scope inputs before the mutation so the resulting
2379        // Damage covers the changed region (the S3 seal — conservative widen).
2380        let lines_before = self.active_line_count();
2381        // Snapshot for the dot register: the only reliable witness that this
2382        // action changed text is that the buffer's revision moved.
2383        let rev_before = self.text_rev();
2384        let cline_before = self.cursor().line;
2385        match action {
2386            // Every action with an exact slip equivalent goes through the
2387            // interpreter, so "undo" has ONE implementation rather than one
2388            // per entry point. These had already drifted: the executor
2389            // re-followed the viewport after undo and the M1 interpreter did
2390            // not, so `u` and `:undo` behaved differently within a milestone
2391            // of each other.
2392            // Listed EXPLICITLY rather than behind a `if lower(..).is_some()`
2393            // guard: a guard arm does not count toward exhaustiveness, so the
2394            // guarded form silently gave up the total match — the compiler
2395            // said so, and it was right. `lowering_and_dispatch_agree` pins
2396            // that this list and `lower` stay the same set.
2397            Action::Quit
2398            | Action::ClearSearchHighlight
2399            | Action::Save
2400            | Action::Undo
2401            | Action::Redo
2402            | Action::Edit(_) => {
2403                for slip in Self::lower(action, self.active).unwrap_or_default() {
2404                    self.honour_one(slip);
2405                }
2406            }
2407            Action::Move(m) => self.apply_motion(*m),
2408            Action::SearchOpen(dir) => {
2409                // vim's `/` is the command-line with a different prompt char,
2410                // so we reuse Command mode; `search.prompt` is what tells a
2411                // later <CR> this is a search and not an ex-command.
2412                let origin = self.cursor_char();
2413                self.search.open(*dir, origin);
2414                self.modal.enter(Mode::Command);
2415            }
2416            Action::SearchRepeat { reverse } => self.jump_search(*reverse),
2417            Action::SearchWord { reverse } => {
2418                let dir = if *reverse {
2419                    SearchDirection::Backward
2420                } else {
2421                    SearchDirection::Forward
2422                };
2423                let (text, at) = (self.active_text(), self.cursor_char());
2424                // `*` jumps, so it records too.
2425                self.jumps.push(self.spot());
2426                match self.search.search_word(&text, at, dir) {
2427                    Some(step) => self.land_on(step),
2428                    // vim beeps and stays put when there is no word under the
2429                    // cursor; a silent no-op would look like a broken key.
2430                    None => self
2431                        .messages
2432                        .push("E348: No string under cursor".to_string()),
2433                }
2434            }
2435            Action::SearchSubmitOperated { op } => self.submit_search_operated(*op),
2436            Action::TextObject(object) => {
2437                // Bare `gn` moves onto the match. vim additionally starts a
2438                // Visual selection of it; escriba's Visual plumbing does not
2439                // carry a selection an operator can consume yet, so this
2440                // stops at the jump rather than faking a selection that
2441                // nothing would honour.
2442                if let Some(range) = self.resolve_object(*object) {
2443                    self.jumps.push(self.spot());
2444                    self.set_cursor(range.start);
2445                } else {
2446                    self.report_pattern_not_found();
2447                }
2448            }
2449            Action::ApplyOperatorObject { op, object } => match self.resolve_object(*object) {
2450                Some(range) => self.apply_operator_over(*op, range),
2451                None => self.report_pattern_not_found(),
2452            },
2453            Action::RepeatLastChange => self.repeat_last_change(),
2454            Action::JumpBack => {
2455                let here = self.spot();
2456                if let Some(spot) = self.jumps.back(here) {
2457                    self.goto_spot(spot);
2458                } else {
2459                    self.messages
2460                        .push("E662: At start of changelist".to_string());
2461                }
2462            }
2463            Action::JumpForward => {
2464                if let Some(spot) = self.jumps.forward() {
2465                    self.goto_spot(spot);
2466                } else {
2467                    self.messages.push("E663: At end of changelist".to_string());
2468                }
2469            }
2470            Action::ChangeMode(m) => {
2471                // Leaving the cmdline abandons any open search prompt and
2472                // returns the cursor home. The COMMITTED pattern survives —
2473                // cancelling a new search must not erase the old highlights.
2474                if *m == Mode::Normal && self.search.is_prompting() {
2475                    if let Some(origin) = self.search.cancel() {
2476                        if let Some(buf) = self.buffers.get(self.active) {
2477                            let pos = buf.char_to_position(origin);
2478                            self.set_cursor(pos);
2479                        }
2480                    }
2481                }
2482                self.modal.enter(*m);
2483            }
2484            Action::InsertChar(c) => self.insert_char(*c),
2485
2486            Action::SubmitCommand => {
2487                if self.search.is_prompting() {
2488                    self.submit_search();
2489                } else {
2490                    self.submit_command();
2491                }
2492            }
2493            Action::Command { name, args } => self.run_command(name, args),
2494            Action::ApplyOperator { op, motion } => self.apply_operator(*op, *motion),
2495            // The operator-pending FSM consumes Operator keys (begins pending);
2496            // they never reach the executor. Defensive no-op for exhaustiveness.
2497            Action::Operator(_) => {}
2498            Action::PromptCaret { to } => {
2499                // Both prompts have a caret now, and the same keys move it.
2500                if self.search.is_prompting() {
2501                    self.search.move_caret(*to);
2502                } else {
2503                    self.modal.move_minibuffer_caret(*to);
2504                }
2505            }
2506            Action::SearchPreviewStep { forward } => {
2507                if self.search.is_prompting() {
2508                    self.search.preview_step(*forward);
2509                    self.preview_search();
2510                }
2511            }
2512            Action::PromptDelete => {
2513                if self.search.is_prompting() {
2514                    self.search.delete_at_caret();
2515                    self.preview_search();
2516                } else {
2517                    self.modal.delete_minibuffer_at_caret();
2518                }
2519            }
2520            Action::PromptDeleteWord => {
2521                if self.search.is_prompting() {
2522                    self.search.delete_word_before_caret();
2523                    self.preview_search();
2524                }
2525            }
2526            Action::PromptClearToStart => {
2527                if self.search.is_prompting() {
2528                    self.search.clear_before_caret();
2529                    self.preview_search();
2530                }
2531            }
2532            Action::PromptBackspace => {
2533                self.prompt_backspace();
2534                // Shortening the pattern changes which matches exist, so the
2535                // preview must re-run — otherwise the cursor sits on a match
2536                // of a pattern that is no longer typed.
2537                if self.search.is_prompting() {
2538                    self.preview_search();
2539                }
2540            }
2541            Action::PromptHistory { back } => {
2542                if self.search.is_prompting() {
2543                    self.search.history_step(*back);
2544                    // No minibuffer resync: the shadow is the ex-line's store
2545                    // and nothing reads it while a search prompt is open, so
2546                    // rewriting it here was maintaining a copy for no reader.
2547                    self.preview_search();
2548                }
2549            }
2550            Action::Pending => {}
2551        }
2552        // Widen the dirty region by what this action touched (M1). Content
2553        // mutations that changed the line count run to end-of-document (every
2554        // line below shifted); an in-place edit or a cursor move is local;
2555        // arbitrary commands are conservatively Full. Never narrows.
2556        let lines_after = self.active_line_count();
2557        let cline_after = self.cursor().line;
2558        let d = match action {
2559            // A search repaints every highlight in the viewport, not just the
2560            // line the cursor left — so it must widen to Full. Treating it as a
2561            // cursor move would leave stale highlights on untouched lines.
2562            Action::SearchOpen(_)
2563            | Action::PromptHistory { .. }
2564            | Action::PromptBackspace
2565            | Action::PromptCaret { .. }
2566            | Action::SearchPreviewStep { .. }
2567            | Action::PromptDelete
2568            | Action::PromptDeleteWord
2569            | Action::PromptClearToStart
2570            | Action::SearchRepeat { .. }
2571            | Action::SearchWord { .. }
2572            | Action::ClearSearchHighlight
2573            | Action::SearchSubmitOperated { .. }
2574            // A replayed change can edit anywhere the original could, and a
2575            // match object can be anywhere in the document.
2576            | Action::RepeatLastChange
2577            | Action::TextObject(_)
2578            | Action::ApplyOperatorObject { .. }
2579            // A jump can land anywhere, so the viewport may scroll wholesale.
2580            | Action::JumpBack
2581            | Action::JumpForward => Damage::Full,
2582            Action::InsertChar(_)
2583            | Action::Edit(_)
2584            | Action::Undo
2585            | Action::Redo
2586            | Action::ApplyOperator { .. } => {
2587                if lines_after == lines_before {
2588                    Damage::span(cline_before, cline_after)
2589                } else {
2590                    Damage::Lines {
2591                        from: cline_before.min(cline_after),
2592                        to: u32::MAX,
2593                    }
2594                }
2595            }
2596            Action::Move(_) | Action::ChangeMode(_) => Damage::span(cline_before, cline_after),
2597            Action::Save => Damage::Viewport,
2598            Action::Command { .. } | Action::SubmitCommand => Damage::Full,
2599            Action::Quit | Action::Operator(_) | Action::Pending => Damage::None,
2600        };
2601        self.damage = self.damage.join(d);
2602        // Remember this change for `.`.
2603        //
2604        // Recorded from an OBSERVED MUTATION, not from the action's variant.
2605        // `text_effect()` is the wrong predicate here even though it looks
2606        // like the right one: it exists to decide cache invalidation, where
2607        // OVER-reporting is the safe direction, and the dot register needs the
2608        // opposite bias. Leaning on it meant `last_change` was set by actions
2609        // that changed no text at all, with two measured consequences:
2610        //
2611        //   `iZ<Esc>` then `/a<CR>` then `.`  — did nothing; the register held
2612        //       `SubmitCommand`, whose replay reads an already-cleared
2613        //       minibuffer.
2614        //   `iZ<Esc>` then `/q<Esc>` then `.` — TYPED `q` INTO THE BUFFER. An
2615        //       abandoned prompt left the register holding `InsertChar('q')`,
2616        //       and `.` in Normal mode routes that to the text. A corrupting
2617        //       register, not merely a lost one.
2618        //
2619        // Comparing the buffer's `TextRev` across the action answers the only
2620        // question that matters — did this actually change the text — and gets
2621        // the failed-operator case (`dgn` with no pattern) right for free.
2622        if self.recording_insert {
2623            match action {
2624                Action::InsertChar(c) => {
2625                    if let Some(lc) = self.last_change.as_mut() {
2626                        lc.inserted.push(*c);
2627                    }
2628                }
2629                // Leaving Insert ends the session; the change is now whole.
2630                Action::ChangeMode(m) if *m != Mode::Insert => self.recording_insert = false,
2631                _ => {}
2632            }
2633        } else if self.text_rev() != rev_before
2634            && !matches!(
2635                action,
2636                Action::RepeatLastChange | Action::Undo | Action::Redo
2637            )
2638        {
2639            self.last_change = Some(LastChange {
2640                action: action.clone(),
2641                count: 1,
2642                inserted: String::new(),
2643            });
2644            self.recording_insert = self.modal.mode() == Mode::Insert;
2645        }
2646
2647        // The search is over the moment you move on or edit — clear the
2648        // highlight rather than leaving the buffer as confetti until an
2649        // explicit `:noh`, which is the remap nearly every vimrc carries.
2650        // Clearing suppresses without forgetting, so `n` still works.
2651        if action.highlight_effect() == HighlightEffect::Clear {
2652            self.search.clear_highlight();
2653        }
2654        // Text changed ⇒ every match offset cached against the old text is
2655        // wrong. `SearchState::refresh` existed for exactly this and had ZERO
2656        // callers, so inserting four characters left both renderers painting
2657        // the highlight four columns off.
2658        //
2659        // Gated on the typed classifier rather than on `bump_gen` (which fires
2660        // for pure cursor moves too): re-scanning the document on every `j`
2661        // would be a per-keystroke full pass for no reason.
2662        if action.text_effect() == TextEffect::Mutates && self.search.pattern().is_some() {
2663            let text = self.active_text();
2664            self.search.refresh(&text);
2665            // NO manual invalidation of `search_at` here, deliberately. It is
2666            // `Anchored` to the text revision, so an ordinal computed against
2667            // the old text now reads as `None` on its own. This is the line
2668            // that used to have to be remembered.
2669        }
2670        // An action reached the executor ⇒ visible state may have changed.
2671        // Advance the refresh generation so the renderer repaints (and
2672        // re-highlights) exactly once. A gated-out key never reaches here, so
2673        // a key-repeat storm does not spin the renderer.
2674        self.bump_gen();
2675    }
2676
2677    /// Resolve a [`Motion`] from `from` to its target [`Position`] against the
2678    /// active buffer — **pure**: no cursor mutation, no side effects. This is
2679    /// the single motion-resolution source of truth that both [`apply_motion`]
2680    /// (move the cursor *to* the target) and [`apply_operator`] (use the target
2681    /// as the *other end* of an operated range) stand on. `None` only if there
2682    /// is no active buffer.
2683    ///
2684    /// [`apply_motion`]: Self::apply_motion
2685    /// [`apply_operator`]: Self::apply_operator
2686    fn resolve_motion(&self, from: Position, motion: Motion) -> Option<Position> {
2687        let buf = self.buffers.get(self.active)?;
2688        let pos = from;
2689        Some(match motion {
2690            // Search-as-motion: what makes `dn` / `d/foo<CR>` work. Resolved
2691            // against the committed match list, so it is `None` (motion fails,
2692            // operator aborts, buffer untouched) when nothing is committed —
2693            // never a silent move to 0, which would delete to the file start.
2694            Motion::SearchNext | Motion::SearchPrev => {
2695                let at = buf.position_to_char(pos).ok()?;
2696                let step = self
2697                    .search
2698                    .repeat(at, matches!(motion, Motion::SearchPrev))?;
2699                buf.char_to_position(step.target.start)
2700            }
2701            Motion::Left => Position::new(pos.line, pos.column.saturating_sub(1)),
2702            Motion::Right => Position::new(pos.line, pos.column.saturating_add(1)),
2703            Motion::Up => Position::new(pos.line.saturating_sub(1), pos.column),
2704            Motion::Down => Position::new(pos.line.saturating_add(1), pos.column),
2705            Motion::LineStart => Position::new(pos.line, 0),
2706            Motion::LineEnd => Position::new(pos.line, buf.line_len_chars(pos.line)),
2707            Motion::LineFirstNonBlank => first_non_blank(buf, pos.line),
2708            Motion::DocStart => Position::ZERO,
2709            Motion::DocEnd => Position::new(
2710                buf.line_count().saturating_sub(1),
2711                buf.line_len_chars(buf.line_count().saturating_sub(1)),
2712            ),
2713            Motion::WordStartNext | Motion::WordEndNext => word_next(buf, pos),
2714            Motion::WordStartPrev => word_prev(buf, pos),
2715            Motion::PageDown | Motion::HalfPageDown => {
2716                Position::new(pos.line.saturating_add(10), pos.column)
2717            }
2718            Motion::PageUp | Motion::HalfPageUp => {
2719                Position::new(pos.line.saturating_sub(10), pos.column)
2720            }
2721            Motion::GotoLine(n) => Position::new(n.saturating_sub(1), 0),
2722            // Structural Lisp motions — stubs for phase 1.B; full paredit
2723            // semantics land when caixa-ast is wired to the active buffer.
2724            Motion::ForwardSexp
2725            | Motion::BackwardSexp
2726            | Motion::UpList
2727            | Motion::DownList
2728            | Motion::BeginningOfDefun
2729            | Motion::EndOfDefun
2730            | Motion::BeginningOfSexp
2731            | Motion::EndOfSexp => pos,
2732        })
2733    }
2734
2735    fn apply_motion(&mut self, motion: Motion) {
2736        // A bare search motion is a FAR JUMP and it REPORTS — it records into
2737        // the jumplist, prints vim's "hit BOTTOM" on a wrap, and says E486
2738        // when nothing matches. `resolve_motion` can do none of that: it is
2739        // deliberately pure because the OPERATOR path calls it to find a range
2740        // without moving the cursor. So `n` routes to the one executor that
2741        // owns those side effects, and `Action::SearchRepeat` routes to the
2742        // same place — one code path, two spellings.
2743        if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
2744            self.jump_search(matches!(motion, Motion::SearchPrev));
2745            return;
2746        }
2747        let Some(pos) = self.resolve_motion(self.cursor(), motion) else {
2748            return;
2749        };
2750        // The single cursor-mutation path clamps to the buffer and scrolls
2751        // the viewport to contain the cursor on both axes.
2752        self.set_cursor(pos);
2753    }
2754
2755    /// Apply an operator over a motion — the vim `{operator}{motion}` verbs
2756    /// (`dw` delete-word, `c$` change-to-line-end, `y0` yank-to-line-start).
2757    /// Composition is explicit: the motion resolves a target via
2758    /// [`resolve_motion`](Self::resolve_motion); the operator acts over the
2759    /// `[cursor, target)` range. Register-leaving operators
2760    /// ([`Operator::leaves_register`]) capture the text first.
2761    /// Apply `op` over `motion` resolved `n` times from the cursor.
2762    ///
2763    /// `n == 1` is the ordinary path. Larger `n` walks the motion forward
2764    /// first and operates over the whole span in one go, which is what vim
2765    /// means by `3dw` — and the only way a non-moving operator like yank can
2766    /// honour a count at all.
2767    fn apply_operator_n(&mut self, op: Operator, motion: Motion, n: u32) {
2768        if n <= 1 {
2769            self.apply_operator(op, motion);
2770            return;
2771        }
2772        let from = self.cursor();
2773        let mut to = from;
2774        for _ in 0..n {
2775            match self.resolve_motion(to, motion) {
2776                Some(next) if next != to => to = next,
2777                // The motion stopped making progress (start/end of buffer):
2778                // operate over what we reached rather than aborting, which is
2779                // what vim does for `999dw` near the end of a file.
2780                _ => break,
2781            }
2782        }
2783        if to == from {
2784            // Nothing to operate over. Fall through to the single-step path
2785            // so its error reporting (E35, pattern-not-found) still runs.
2786            self.apply_operator(op, motion);
2787            return;
2788        }
2789        self.apply_operator_to(op, to);
2790    }
2791
2792    fn apply_operator(&mut self, op: Operator, motion: Motion) {
2793        let from = self.cursor();
2794        let Some(to) = self.resolve_motion(from, motion) else {
2795            // A motion that cannot resolve aborts the operator with the buffer
2796            // untouched. A search motion says WHY — `dn` with no pattern armed
2797            // is otherwise indistinguishable from a dropped keystroke, which
2798            // is the same complaint that motivated E486 on the bare path.
2799            if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
2800                if self.search.pattern().is_none() {
2801                    self.messages
2802                        .push("E35: No previous regular expression".to_string());
2803                } else {
2804                    self.report_pattern_not_found();
2805                }
2806            }
2807            return;
2808        };
2809        self.apply_operator_to(op, to);
2810    }
2811
2812    /// Apply `op` over `[cursor, to)`.
2813    ///
2814    /// Split out of [`Self::apply_operator`] so the operated-search path can
2815    /// reach the same range machinery with a target it resolved itself — the
2816    /// alternative was a second copy of the delete/yank/register logic, which
2817    /// is how the two would drift.
2818    fn apply_operator_to(&mut self, op: Operator, to: Position) {
2819        let from = self.cursor();
2820        self.apply_operator_over(
2821            op,
2822            Range {
2823                start: from,
2824                end: to,
2825            },
2826        );
2827    }
2828
2829    /// Apply `op` over an explicit range.
2830    ///
2831    /// The object path needs this: `gn`'s extent need not begin at the cursor,
2832    /// so it cannot go through the `[cursor, target)` shape the motion path
2833    /// uses. One implementation of the delete/yank/register logic, reached two
2834    /// ways.
2835    fn apply_operator_over(&mut self, op: Operator, range: Range) {
2836        let range = range.normalized();
2837        if range.is_empty() {
2838            return;
2839        }
2840        // Capture the operated text (for the register) before mutating.
2841        let text = self
2842            .buffers
2843            .get(self.active)
2844            .and_then(|buf| buf.slice(range).ok());
2845        if op.leaves_register() {
2846            if let Some(t) = &text {
2847                self.register = Some(t.clone());
2848            }
2849        }
2850        match op {
2851            // Delete + Change remove the range; Change then enters Insert so
2852            // the operator pairs with immediate typing (`ciw`, `c$`).
2853            Operator::Delete | Operator::Change => {
2854                if let Some(buf) = self.buffers.get_mut(self.active) {
2855                    let _ = buf.apply(&Edit::delete(range));
2856                }
2857                self.set_cursor(range.start);
2858                if op == Operator::Change {
2859                    self.modal.enter(Mode::Insert);
2860                }
2861            }
2862            // Yank copies to the register without mutating the buffer; vim
2863            // leaves the cursor at the range start.
2864            Operator::Yank => {
2865                self.set_cursor(range.start);
2866            }
2867            // Indent/Format/structural operators are not yet wired — named,
2868            // not faked (no buffer mutation, register already captured for the
2869            // register-leaving ones above).
2870            _ => {
2871                self.messages
2872                    .push("operator not yet implemented".to_owned());
2873            }
2874        }
2875    }
2876
2877    /// The text last yanked or deleted into the unnamed register, if any.
2878    /// The future `p`/`P` paste reads this.
2879    #[must_use]
2880    pub fn register(&self) -> Option<&str> {
2881        self.register.as_deref()
2882    }
2883
2884    fn insert_char(&mut self, c: char) {
2885        if self.modal.mode() == Mode::Command {
2886            // A search prompt and an ex-command share Command mode (vim's
2887            // cmdline). `search.is_prompting()` is the typed discriminator —
2888            // it can only be true when `/` or `?` actually opened a prompt.
2889            if self.search.is_prompting() {
2890                // The search prompt is the SOLE store while it is open.
2891                //
2892                // This used to also `push_minibuffer(c)`, and the two stores
2893                // insert differently — `search.push` at the caret, the
2894                // minibuffer always at the end — so `/fo<Left>X` left them
2895                // reading `fXo` and `foX`. That was one of FIVE desync paths;
2896                // the caret moves, forward-delete, delete-word and
2897                // clear-to-start never touched the shadow at all.
2898                //
2899                // Deleting the write costs nothing because `status_model`
2900                // already selects the minibuffer only on the `prompt == None`
2901                // branch — the shadow is the EX-LINE's store, and while a
2902                // search prompt is open nothing reads it.
2903                self.search.push(c);
2904                self.preview_search();
2905            } else {
2906                self.modal.push_minibuffer(c);
2907            }
2908            return;
2909        }
2910        let cursor = self.cursor();
2911        let Some(buf) = self.buffers.get_mut(self.active) else {
2912            return;
2913        };
2914        let edit = Edit::insert(cursor, c.to_string());
2915        if buf.apply(&edit).is_ok() {
2916            let next = if c == '\n' {
2917                Position::new(cursor.line.saturating_add(1), 0)
2918            } else {
2919                cursor.shift_right(1)
2920            };
2921            // Route through the single cursor-mutation path so the viewport
2922            // follows the cursor (both axes) and the cursor stays clamped.
2923            self.set_cursor(next);
2924        }
2925    }
2926
2927    /// Backspace inside a prompt. Keeps the search buffer and the displayed
2928    /// minibuffer in lockstep — if only one shrank, the pattern submitted
2929    /// would differ from the text on screen.
2930    fn prompt_backspace(&mut self) -> bool {
2931        if self.modal.mode() != Mode::Command {
2932            return false;
2933        }
2934        if self.search.is_prompting() {
2935            // Backspacing past the `/` closes the prompt, as vim does. No
2936            // `pop_minibuffer` here for the same reason as `insert_char`: the
2937            // shadow is the ex-line's, and popping its TAIL when the caret is
2938            // mid-pattern was another desync path.
2939            if self.search.backspace() {
2940                self.modal.clear_minibuffer();
2941                self.modal.enter(Mode::Normal);
2942            }
2943            // Never `pop_minibuffer` on the search path: it pops the TAIL,
2944            // while `search.backspace()` removes the char before the CARET.
2945            return true;
2946        }
2947        self.modal.pop_minibuffer();
2948        true
2949    }
2950
2951    fn submit_command(&mut self) {
2952        // Read the command line BEFORE leaving Command mode — the minibuffer
2953        // exists only in the `Command` variant, so the escape must come
2954        // after the capture.
2955        let line = self.modal.minibuffer().to_string();
2956        self.modal.escape();
2957        let (name, args) = parse_command_line(&line);
2958        if name.is_empty() {
2959            return;
2960        }
2961        self.run_command(&name, &args);
2962    }
2963
2964    fn run_command(&mut self, name: &str, args: &[String]) {
2965        // Bound the command -> RunCommand slip -> command cycle. Refused and
2966        // reported, never a stack overflow: an editor that dies under the
2967        // operator loses their buffer, and a script that loops is a mistake
2968        // they should be told about, not punished for.
2969        if self.dispatch_depth >= Self::MAX_DISPATCH_DEPTH {
2970            let mut m = String::from("command recursion too deep at `");
2971            m.push_str(name);
2972            m.push_str("` — refusing");
2973            self.messages.push(m);
2974            self.damage = self.damage.join(Damage::Viewport);
2975            self.bump_gen();
2976            return;
2977        }
2978        self.dispatch_depth += 1;
2979        self.run_command_inner(name, args);
2980        self.dispatch_depth -= 1;
2981    }
2982
2983    /// How many nested command dispatches are allowed. Deep enough that no
2984    /// legitimate script notices, shallow enough to fail fast.
2985    const MAX_DISPATCH_DEPTH: u8 = 8;
2986
2987    fn run_command_inner(&mut self, name: &str, args: &[String]) {
2988        // Lazy-activation seam (lazy.nvim `cmd =` model): a user plugin
2989        // gated on `Command: <name>` has its entry applied the first time
2990        // that command runs, BEFORE dispatch — so the activated plugin
2991        // can register the very command being invoked and it resolves on
2992        // this same call.
2993        if self.plugin_host.pending() > 0 {
2994            let pending = self.plugin_host.pending_for_command(name);
2995            for src in pending {
2996                self.apply_plugin_entry(&src);
2997            }
2998        }
2999        // Read through the counter, then interpret. Two immutable borrows of
3000        // `self` (the window and the registry) coexist; the `&mut` comes
3001        // afterwards, once the outcome is owned. That sequencing IS the
3002        // seam: there is no moment where a command body and `&mut self` are
3003        // live at the same time.
3004        let outcome = {
3005            let window = self.window();
3006            self.commands.run(name, &window, args)
3007        };
3008        match outcome {
3009            Ok(o) => self.interpret(o),
3010            // Reported, never fatal (Phase 0). A failed command must not
3011            // take the editor down, but it must not be invisible either.
3012            Err(e) => {
3013                self.messages.push(describe_command_failure(name, &e));
3014                self.damage = self.damage.join(Damage::Viewport);
3015                self.bump_gen();
3016            }
3017        }
3018    }
3019
3020    // ── tatara-lisp runtime bridge (imperative programmability tier) ──
3021
3022    /// Capture a read snapshot of the editor for the tatara-lisp host.
3023    /// Lisp reads (`cursor-line`, `current-line`, …) answer from this.
3024    #[must_use]
3025    pub fn snapshot(&self) -> EditorSnapshot {
3026        let current_line = self
3027            .buffers
3028            .get(self.active)
3029            .and_then(|b| b.line(self.cursor().line))
3030            .map(|s| s.trim_end_matches('\n').to_string())
3031            .unwrap_or_default();
3032        let buffer_name = self
3033            .buffers
3034            .get(self.active)
3035            .and_then(|b| b.path.as_ref())
3036            .map(|p| p.display().to_string())
3037            .unwrap_or_else(|| "[scratch]".to_string());
3038        EditorSnapshot {
3039            cursor_line: i64::from(self.cursor().line),
3040            cursor_column: i64::from(self.cursor().column),
3041            current_line,
3042            mode: self.modal.mode().as_str().to_string(),
3043            buffer_name,
3044        }
3045    }
3046
3047    /// Evaluate tatara-lisp `src` against this editor: capture a
3048    /// snapshot, run it in the embedded VM, then apply the typed effects
3049    /// the program emitted. This is the imperative programmability tier
3050    /// — live Lisp that reads state and drives the editor through the
3051    /// sandboxed effect boundary.
3052    ///
3053    /// **Snapshot semantics:** the read snapshot is captured ONCE before
3054    /// eval, and effects are applied AFTER the program returns. So within
3055    /// a single `run_lisp` call a program cannot observe its own writes —
3056    /// `(insert "x") (cursor-column)` reads the pre-insert column. This
3057    /// snapshot-isolation is deliberate (it's what makes the effect
3058    /// boundary a clean sandbox seam); a program that must read its own
3059    /// effects splits the work across calls. The VM is cached
3060    /// ([`Self::lisp_vm`]) so the stdlib is installed once and top-level
3061    /// `define`s persist across calls (REPL-like).
3062    pub fn run_lisp(&mut self, src: &str) -> Result<(), VmError> {
3063        let mut host = EscribaHost::with_snapshot(self.snapshot());
3064        let vm = self.lisp_vm.get_or_insert_with(EscribaVm::new);
3065        vm.eval(src, &mut host)?;
3066        let effects = host.take_effects();
3067        self.apply_host_effects(effects);
3068        Ok(())
3069    }
3070
3071    /// Apply tatara-lisp effects to live editor state.
3072    ///
3073    /// A thin adapter now. It used to be `apply_host_effects`, a THIRD
3074    /// implementation of message-push / option-insert / insert-text beside
3075    /// the Action executor and the slip interpreter — the same duplication
3076    /// that let `u` and `:undo` drift apart in M3. The VM emits slips; this
3077    /// hands them to the one interpreter.
3078    pub fn apply_host_effects(&mut self, effects: Vec<Negai>) {
3079        self.interpret(Outcome::did(effects));
3080    }
3081
3082    /// Insert a (possibly multi-line) string at the cursor and advance
3083    /// the cursor past it. Used by the `(insert …)` effect.
3084    fn insert_text(&mut self, text: &str) {
3085        if text.is_empty() {
3086            return;
3087        }
3088        let cursor = self.cursor();
3089        let Some(buf) = self.buffers.get_mut(self.active) else {
3090            return;
3091        };
3092        let edit = Edit::insert(cursor, text.to_string());
3093        if buf.apply(&edit).is_ok() {
3094            let next = if let Some(nl) = text.rfind('\n') {
3095                let added_lines = u32::try_from(text.matches('\n').count()).unwrap_or(0);
3096                let last_line_len = u32::try_from(text[nl + 1..].chars().count()).unwrap_or(0);
3097                Position::new(cursor.line + added_lines, last_line_len)
3098            } else {
3099                let n = u32::try_from(text.chars().count()).unwrap_or(0);
3100                cursor.shift_right(n)
3101            };
3102            // Route through the single cursor-mutation path so the viewport
3103            // follows the cursor (both axes) and the cursor stays clamped.
3104            self.set_cursor(next);
3105        }
3106    }
3107}
3108
3109fn first_non_blank(buf: &escriba_buffer::Buffer, line: u32) -> Position {
3110    let Some(text) = buf.line(line) else {
3111        return Position::new(line, 0);
3112    };
3113    let col = text
3114        .chars()
3115        .take_while(|c| c.is_whitespace() && *c != '\n')
3116        .count();
3117    Position::new(line, u32::try_from(col).unwrap_or(0))
3118}
3119
3120fn word_next(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
3121    let Some(text) = buf.line(pos.line) else {
3122        return pos;
3123    };
3124    let chars: Vec<char> = text.chars().collect();
3125    let start = pos.column as usize;
3126    let mut i = start;
3127    while i < chars.len() && !chars[i].is_whitespace() {
3128        i += 1;
3129    }
3130    while i < chars.len() && chars[i].is_whitespace() {
3131        i += 1;
3132    }
3133    if i >= chars.len() {
3134        // No more words on this line — jump to next line.
3135        if pos.line + 1 < buf.line_count() {
3136            return Position::new(pos.line + 1, 0);
3137        }
3138    }
3139    Position::new(pos.line, u32::try_from(i).unwrap_or(pos.column))
3140}
3141
3142fn word_prev(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
3143    let Some(text) = buf.line(pos.line) else {
3144        return pos;
3145    };
3146    let chars: Vec<char> = text.chars().collect();
3147    let mut i = (pos.column as usize).min(chars.len());
3148    while i > 0 && chars[i - 1].is_whitespace() {
3149        i -= 1;
3150    }
3151    while i > 0 && !chars[i - 1].is_whitespace() {
3152        i -= 1;
3153    }
3154    Position::new(pos.line, u32::try_from(i).unwrap_or(0))
3155}
3156
3157fn parse_command_line(line: &str) -> (String, Vec<String>) {
3158    let mut parts = line.split_whitespace();
3159    let Some(first) = parts.next() else {
3160        return (String::new(), Vec::new());
3161    };
3162    let head = first.strip_prefix(':').unwrap_or(first);
3163    let name = match head {
3164        "w" => "save",
3165        "q" => "quit",
3166        "u" => "undo",
3167        other => other,
3168    };
3169    (name.to_string(), parts.map(str::to_string).collect())
3170}
3171
3172#[cfg(test)]
3173mod tests {
3174    use super::*;
3175    use madori::event::{KeyCode, KeyEvent, Modifiers};
3176
3177    // ── search wiring (escriba-search integration) ────────────────────
3178    //
3179    // The engine is proven in escriba-search's own 61 tests. These prove the
3180    // WIRING: that keys reach it, that the cursor lands where it says, and
3181    // that a search prompt and an ex-command can share Command mode without
3182    // being confused for one another.
3183
3184    fn type_search(st: &mut EditorState, dir: SearchDirection, pat: &str) {
3185        st.apply(&Action::SearchOpen(dir));
3186        for c in pat.chars() {
3187            st.apply(&Action::InsertChar(c));
3188        }
3189        st.apply(&Action::SubmitCommand);
3190    }
3191
3192    #[test]
3193    fn slash_search_moves_the_cursor_to_the_match() {
3194        let mut st = new_state_with("alpha\nbravo\ncharlie\n");
3195        type_search(&mut st, SearchDirection::Forward, "charlie");
3196        assert_eq!(st.cursor().line, 2, "cursor lands on the matching line");
3197        assert_eq!(st.modal.mode(), Mode::Normal, "prompt closes on submit");
3198        assert_eq!(st.search.matches().len(), 1);
3199    }
3200
3201    #[test]
3202    // `N` is a DIFFERENT vim key from `n` — see escriba-search.
3203    #[allow(non_snake_case)]
3204    fn n_and_N_walk_matches_in_both_directions() {
3205        let mut st = new_state_with("foo\nbar\nfoo\nbaz\nfoo\n");
3206        type_search(&mut st, SearchDirection::Forward, "foo");
3207        let first = st.cursor().line;
3208        st.apply(&Action::SearchRepeat { reverse: false });
3209        let second = st.cursor().line;
3210        assert!(second > first, "n advances ({first} -> {second})");
3211        st.apply(&Action::SearchRepeat { reverse: true });
3212        assert_eq!(st.cursor().line, first, "N comes back");
3213    }
3214
3215    #[test]
3216    fn star_searches_the_word_under_the_cursor() {
3217        let mut st = new_state_with("needle\nhaystack\nneedle\n");
3218        st.apply(&Action::SearchWord { reverse: false });
3219        assert_eq!(st.search.pattern().unwrap().raw(), r"\bneedle\b");
3220        assert_eq!(st.cursor().line, 2, "jumps to the other occurrence");
3221    }
3222
3223    #[test]
3224    fn escape_abandons_the_prompt_and_keeps_the_previous_search() {
3225        let mut st = new_state_with("foo\nbar\nfoo\n");
3226        type_search(&mut st, SearchDirection::Forward, "foo");
3227        let matches_before = st.search.matches().len();
3228
3229        st.apply(&Action::SearchOpen(SearchDirection::Forward));
3230        st.apply(&Action::InsertChar('z'));
3231        st.apply(&Action::ChangeMode(Mode::Normal));
3232
3233        assert!(!st.search.is_prompting(), "prompt gone");
3234        assert_eq!(
3235            st.search.pattern().unwrap().raw(),
3236            "foo",
3237            "old pattern survives"
3238        );
3239        assert_eq!(
3240            st.search.matches().len(),
3241            matches_before,
3242            "old highlights survive"
3243        );
3244    }
3245
3246    #[test]
3247    fn a_search_prompt_and_an_ex_command_are_not_confused() {
3248        let mut st = new_state_with("foo\n");
3249        // No `/` pressed: Command mode belongs to the ex-command line.
3250        st.apply(&Action::ChangeMode(Mode::Command));
3251        assert!(!st.search.is_prompting(), "`:` must not open a search");
3252        st.apply(&Action::InsertChar('w'));
3253        assert!(
3254            st.search.prompt().is_none(),
3255            "typed char went to the ex line"
3256        );
3257    }
3258
3259    #[test]
3260    fn a_missing_pattern_reports_instead_of_failing_silently() {
3261        let mut st = new_state_with("alpha\nbravo\n");
3262        type_search(&mut st, SearchDirection::Forward, "zzz");
3263        assert!(
3264            st.messages.iter().any(|m| m.contains("E486")),
3265            "must report not-found, got {:?}",
3266            st.messages
3267        );
3268    }
3269
3270    #[test]
3271    fn n_without_any_search_reports_rather_than_moving() {
3272        let mut st = new_state_with("alpha\nbravo\n");
3273        let before = st.cursor();
3274        st.apply(&Action::SearchRepeat { reverse: false });
3275        assert_eq!(st.cursor(), before, "cursor must not move");
3276        assert!(
3277            st.messages.iter().any(|m| m.contains("E35")),
3278            "got {:?}",
3279            st.messages
3280        );
3281    }
3282
3283    #[test]
3284    fn search_as_a_motion_composes_with_an_operator() {
3285        // The point of Motion::SearchNext: `d` + search deletes to the match.
3286        let mut st = new_state_with("alpha bravo charlie\n");
3287        type_search(&mut st, SearchDirection::Forward, "charlie");
3288        st.set_cursor(Position::new(0, 0));
3289        let target = st.resolve_motion(Position::new(0, 0), Motion::SearchNext);
3290        assert!(target.is_some(), "search must resolve as a motion");
3291        assert_eq!(target.unwrap().column, 12, "at `charlie`");
3292    }
3293
3294    #[test]
3295    fn search_motion_without_a_pattern_fails_the_motion_instead_of_moving_to_zero() {
3296        // A silent fallback to offset 0 would make `d` + search delete to the
3297        // start of the file — the worst possible failure for an operator.
3298        let st = new_state_with("alpha bravo\n");
3299        assert!(
3300            st.resolve_motion(Position::new(0, 5), Motion::SearchNext)
3301                .is_none()
3302        );
3303    }
3304
3305    #[test]
3306    fn clear_highlight_keeps_the_pattern_usable() {
3307        let mut st = new_state_with("foo\nbar\nfoo\n");
3308        type_search(&mut st, SearchDirection::Forward, "foo");
3309        st.apply(&Action::ClearSearchHighlight);
3310        assert!(st.search.highlights().is_empty(), "nothing lit");
3311        st.apply(&Action::SearchRepeat { reverse: false });
3312        assert!(st.search.pattern().is_some(), "but n still works");
3313    }
3314
3315    #[test]
3316    fn typing_previews_incrementally_before_commit() {
3317        let mut st = new_state_with("alpha\nbravo\ncharlie\n");
3318        st.apply(&Action::SearchOpen(SearchDirection::Forward));
3319        for c in "charlie".chars() {
3320            st.apply(&Action::InsertChar(c));
3321        }
3322        // incsearch: the cursor has already moved, with nothing committed.
3323        assert_eq!(st.cursor().line, 2, "preview moved the cursor");
3324        assert!(st.search.pattern().is_none(), "but nothing is committed");
3325    }
3326
3327    #[test]
3328    fn backspace_corrects_the_prompt_and_reruns_the_preview() {
3329        let mut st = new_state_with("alpha\nbravo\n");
3330        st.apply(&Action::SearchOpen(SearchDirection::Forward));
3331        for c in "bravox".chars() {
3332            st.apply(&Action::InsertChar(c));
3333        }
3334        assert_eq!(st.search.prompt().unwrap().text(), "bravox");
3335        st.apply(&Action::PromptBackspace);
3336        assert_eq!(
3337            st.search.prompt().unwrap().text(),
3338            "bravo",
3339            "typo corrected"
3340        );
3341        assert_eq!(
3342            st.status_model().prompt_text,
3343            "bravo",
3344            "the model reads the PROMPT — the minibuffer is the ex-line's store",
3345        );
3346        assert_eq!(st.cursor().line, 1, "preview re-ran and found it");
3347    }
3348
3349    #[test]
3350    fn backspacing_past_the_slash_closes_the_prompt() {
3351        let mut st = new_state_with("alpha\n");
3352        st.apply(&Action::SearchOpen(SearchDirection::Forward));
3353        st.apply(&Action::InsertChar('a'));
3354        st.apply(&Action::PromptBackspace);
3355        st.apply(&Action::PromptBackspace);
3356        assert!(!st.search.is_prompting(), "prompt closed");
3357        assert_eq!(st.modal.mode(), Mode::Normal);
3358    }
3359
3360    #[test]
3361    fn noh_clears_highlights_and_keeps_the_pattern() {
3362        let mut st = new_state_with("foo\nbar\nfoo\n");
3363        type_search(&mut st, SearchDirection::Forward, "foo");
3364        assert!(!st.search.highlights().is_empty());
3365        st.run_command("noh", &[]);
3366        assert!(st.search.highlights().is_empty(), ":noh turns them off");
3367        assert!(st.search.pattern().is_some(), "but n still works");
3368    }
3369
3370    #[test]
3371    fn noh_accepts_the_vim_aliases() {
3372        for name in ["noh", "nohl", "nohlsearch"] {
3373            let mut st = new_state_with("foo\nfoo\n");
3374            type_search(&mut st, SearchDirection::Forward, "foo");
3375            st.run_command(name, &[]);
3376            assert!(st.search.highlights().is_empty(), "{name} must clear");
3377        }
3378    }
3379
3380    #[test]
3381    fn backspace_on_the_ex_line_does_not_touch_search_state() {
3382        let mut st = new_state_with("foo\n");
3383        st.apply(&Action::ChangeMode(Mode::Command));
3384        st.apply(&Action::InsertChar('w'));
3385        st.apply(&Action::InsertChar('q'));
3386        st.apply(&Action::PromptBackspace);
3387        assert_eq!(st.status_model().prompt_text, "w");
3388        assert!(st.search.prompt().is_none(), "no search was involved");
3389    }
3390
3391    #[test]
3392    fn up_arrow_recalls_the_previous_search() {
3393        let mut st = new_state_with("alpha\nbravo\n");
3394        type_search(&mut st, SearchDirection::Forward, "bravo");
3395        st.apply(&Action::SearchOpen(SearchDirection::Forward));
3396        st.apply(&Action::PromptHistory { back: true });
3397        assert_eq!(st.search.prompt().unwrap().text(), "bravo");
3398        assert_eq!(
3399            st.status_model().prompt_text,
3400            "bravo",
3401            "display follows the prompt"
3402        );
3403    }
3404
3405    #[test]
3406    fn arrowing_back_down_restores_the_half_typed_pattern() {
3407        let mut st = new_state_with("alpha\nbravo\n");
3408        type_search(&mut st, SearchDirection::Forward, "bravo");
3409        st.apply(&Action::SearchOpen(SearchDirection::Forward));
3410        st.apply(&Action::InsertChar('a'));
3411        st.apply(&Action::PromptHistory { back: true });
3412        assert_eq!(st.search.prompt().unwrap().text(), "bravo");
3413        st.apply(&Action::PromptHistory { back: false });
3414        assert_eq!(
3415            st.search.prompt().unwrap().text(),
3416            "a",
3417            "the draft comes back"
3418        );
3419        assert_eq!(st.status_model().prompt_text, "a");
3420    }
3421
3422    #[test]
3423    fn history_arrows_do_nothing_on_the_ex_line() {
3424        let mut st = new_state_with("alpha\n");
3425        st.apply(&Action::ChangeMode(Mode::Command));
3426        st.apply(&Action::InsertChar('w'));
3427        st.apply(&Action::PromptHistory { back: true });
3428        assert_eq!(st.status_model().prompt_text, "w", "ex line untouched");
3429    }
3430
3431    // ── trouble.* — the findings view ────────────────────────────────
3432    //
3433    // These assert on the ROWS the picker would be built from, not on the
3434    // registry: the registry is already tested, and what could be wrong
3435    // here is the projection — scoping, freshness, and whether a row goes
3436    // anywhere when pressed.
3437
3438    fn finding_at(buffer: BufferId, line: u32, msg: &str) -> escriba_shirube::Finding {
3439        use escriba_core::{Position, Range};
3440        escriba_shirube::Finding::new(
3441            escriba_shirube::Site::in_buffer(
3442                buffer,
3443                Range::new(Position::new(line, 0), Position::new(line, 1)),
3444            ),
3445            escriba_shirube::Severity::Error,
3446            msg.to_string(),
3447            escriba_shirube::Origin::Text("test"),
3448        )
3449    }
3450
3451    #[test]
3452    fn published_findings_become_picker_rows() {
3453        let mut st = new_state_with("a\nb\nc\n");
3454        let world = st.world();
3455        st.results.publish(
3456            "test",
3457            escriba_shirube::ResultList::new(vec![finding_at(st.active, 1, "boom")], world),
3458        );
3459        let rows = st.finding_items(true);
3460        assert_eq!(rows.len(), 1, "the published finding produces a row");
3461        // The row must SAY something an operator can act on: severity,
3462        // 1-based line, and the message.
3463        let label = &rows[0].label;
3464        assert!(label.contains("ERROR"), "{label}");
3465        assert!(label.contains(":2"), "lines are 1-based on screen: {label}");
3466        assert!(label.contains("boom"), "{label}");
3467    }
3468
3469    #[test]
3470    fn a_stale_list_contributes_no_rows() {
3471        // THE load-bearing one. A list anchored to a revision the buffer has
3472        // moved past must vanish from the view rather than offer a line that
3473        // has since shifted — which is the whole reason findings carry an
3474        // anchor instead of just a position.
3475        let mut st = new_state_with("a\nb\nc\n");
3476        let world = st.world();
3477        st.results.publish(
3478            "test",
3479            escriba_shirube::ResultList::new(vec![finding_at(st.active, 1, "boom")], world),
3480        );
3481        assert_eq!(st.finding_items(true).len(), 1, "fresh to begin with");
3482
3483        st.apply(&Action::InsertChar('x'));
3484        assert!(
3485            st.finding_items(true).is_empty(),
3486            "an edit moved the text on; the list is stale and must not be shown"
3487        );
3488    }
3489
3490    #[test]
3491    fn document_scope_excludes_another_buffer() {
3492        // `trouble.document` vs `trouble.workspace` is one bool, so this is
3493        // the only thing that can distinguish them.
3494        let mut st = new_state_with("a\nb\n");
3495        let other = st.buffers.scratch("z\n");
3496        let world = st.world();
3497        st.results.publish(
3498            "test",
3499            escriba_shirube::ResultList::new(
3500                vec![
3501                    finding_at(st.active, 0, "mine"),
3502                    finding_at(other, 0, "theirs"),
3503                ],
3504                world,
3505            ),
3506        );
3507        let ws = st.finding_items(true);
3508        assert_eq!(ws.len(), 2, "workspace scope shows both");
3509        let doc = st.finding_items(false);
3510        assert_eq!(doc.len(), 1, "document scope shows only the active buffer");
3511        assert!(doc[0].label.contains("mine"), "{}", doc[0].label);
3512    }
3513
3514    #[test]
3515    fn files_under_a_root_produces_rows() {
3516        // `files.open-parent` differs from `files.open` only in the root, so
3517        // what must hold is that a root is actually honoured.
3518        let mut st = new_state_with("");
3519        let rows = st.file_items(std::path::Path::new("."));
3520        assert!(!rows.is_empty(), "the working directory has files");
3521    }
3522
3523    // ── vim text objects ─────────────────────────────────────────────
3524    //
3525    // Asserted through `apply` on real buffer text, so a wrong RANGE shows
3526    // up as wrong text rather than as a range that merely looks plausible.
3527
3528    fn after(text: &str, line: u32, col: u32, act: Action) -> String {
3529        let mut st = new_state_with(text);
3530        st.set_cursor(Position::new(line, col));
3531        st.apply(&act);
3532        st.buffers
3533            .get(st.active)
3534            .map(|b| b.to_string())
3535            .unwrap_or_default()
3536    }
3537
3538    fn del_obj(o: escriba_core::TextObject) -> Action {
3539        Action::ApplyOperatorObject {
3540            op: escriba_core::Operator::Delete,
3541            object: o,
3542        }
3543    }
3544
3545    #[test]
3546    fn dd_removes_the_line_not_just_its_contents() {
3547        // The distinction the newline makes: without it, `dd` blanks a line
3548        // and leaves it behind.
3549        let got = after("a\nb\nc\n", 1, 0, del_obj(escriba_core::TextObject::Line));
3550        assert_eq!(got, "a\nc\n");
3551    }
3552
3553    #[test]
3554    fn dd_on_the_last_line_leaves_no_blank_behind() {
3555        // The case a naive start-of-line..start-of-next range gets wrong:
3556        // there is no following newline to take, so it must take the
3557        // preceding one.
3558        let got = after("a\nb\nc\n", 2, 0, del_obj(escriba_core::TextObject::Line));
3559        assert_eq!(got, "a\nb\n", "no trailing empty line: {got:?}");
3560    }
3561
3562    #[test]
3563    fn dd_on_the_only_line_clears_it_but_keeps_the_line() {
3564        let got = after("solo\n", 0, 2, del_obj(escriba_core::TextObject::Line));
3565        assert!(got.starts_with('\n') || got.is_empty(), "{got:?}");
3566    }
3567
3568    #[test]
3569    fn diw_takes_the_word_and_daw_takes_its_trailing_space() {
3570        let inner = after(
3571            "one two three\n",
3572            0,
3573            5,
3574            del_obj(escriba_core::TextObject::Word { around: false }),
3575        );
3576        assert_eq!(inner, "one  three\n", "iw leaves both spaces");
3577        let around = after(
3578            "one two three\n",
3579            0,
3580            5,
3581            del_obj(escriba_core::TextObject::Word { around: true }),
3582        );
3583        assert_eq!(around, "one three\n", "aw takes the trailing space");
3584    }
3585
3586    #[test]
3587    fn iw_from_any_column_inside_the_word_takes_the_whole_word() {
3588        for col in 4..=6 {
3589            let got = after(
3590                "one two three\n",
3591                0,
3592                col,
3593                del_obj(escriba_core::TextObject::Word { around: false }),
3594            );
3595            assert_eq!(got, "one  three\n", "from column {col}");
3596        }
3597    }
3598
3599    #[test]
3600    fn iw_on_punctuation_takes_the_punctuation_run() {
3601        // vim's three classes: word / punctuation / whitespace. A `::` is a
3602        // run of punctuation, not part of either identifier.
3603        let got = after(
3604            "foo::bar\n",
3605            0,
3606            3,
3607            del_obj(escriba_core::TextObject::Word { around: false }),
3608        );
3609        assert_eq!(got, "foobar\n");
3610    }
3611
3612    #[test]
3613    fn i_paren_takes_the_inside_and_a_paren_takes_the_brackets_too() {
3614        let inner = after(
3615            "f(a, b)\n",
3616            0,
3617            3,
3618            del_obj(escriba_core::TextObject::Delimited {
3619                open: '(',
3620                close: ')',
3621                around: false,
3622            }),
3623        );
3624        assert_eq!(inner, "f()\n");
3625        let around = after(
3626            "f(a, b)\n",
3627            0,
3628            3,
3629            del_obj(escriba_core::TextObject::Delimited {
3630                open: '(',
3631                close: ')',
3632                around: true,
3633            }),
3634        );
3635        assert_eq!(around, "f\n");
3636    }
3637
3638    #[test]
3639    fn nested_brackets_resolve_to_the_enclosing_pair() {
3640        // THE reason the bracket scan counts depth: an inner pair must not
3641        // terminate the search for the one the cursor is actually inside.
3642        let got = after(
3643            "f(g(x), y)\n",
3644            0,
3645            8,
3646            del_obj(escriba_core::TextObject::Delimited {
3647                open: '(',
3648                close: ')',
3649                around: false,
3650            }),
3651        );
3652        assert_eq!(got, "f()\n", "took the outer pair");
3653    }
3654
3655    #[test]
3656    fn quotes_do_not_nest_so_the_nearest_pair_wins() {
3657        let got = after(
3658            r#"say "hi there" ok"#,
3659            0,
3660            7,
3661            del_obj(escriba_core::TextObject::Delimited {
3662                open: '"',
3663                close: '"',
3664                around: false,
3665            }),
3666        );
3667        assert_eq!(got, "say \"\" ok");
3668    }
3669
3670    #[test]
3671    fn an_unmatched_delimiter_resolves_to_nothing_rather_than_guessing() {
3672        let mut st = new_state_with("f(a, b\n");
3673        st.set_cursor(Position::new(0, 3));
3674        let before = st
3675            .buffers
3676            .get(st.active)
3677            .map(|b| b.to_string())
3678            .unwrap_or_default();
3679        st.apply(&del_obj(escriba_core::TextObject::Delimited {
3680            open: '(',
3681            close: ')',
3682            around: false,
3683        }));
3684        let got_after = st
3685            .buffers
3686            .get(st.active)
3687            .map(|b| b.to_string())
3688            .unwrap_or_default();
3689        assert_eq!(got_after, before, "no closing bracket: change nothing");
3690    }
3691
3692    fn new_state_with(text: &str) -> EditorState {
3693        let mut bufs = BufferSet::new();
3694        let id = bufs.scratch(text);
3695        EditorState::new_with_buffer(bufs, id)
3696    }
3697
3698    /// The refresh-seal driver (theory/ESCRIBA.md §Refresh-Seal): an applied
3699    /// action advances `edit_gen` (so the renderer repaints), and merely
3700    /// reading the generation does not. This is what lets `gpu.rs` gate the
3701    /// re-highlight/re-shape on a generation change — an idle frame observes an
3702    /// unchanged generation and reuses its cached buffer.
3703    #[test]
3704    fn edit_gen_advances_on_applied_action_not_on_read() {
3705        let mut s = new_state_with("hello\nworld\n");
3706        let g0 = s.edit_gen();
3707        s.apply(&Action::InsertChar('X'));
3708        assert_ne!(
3709            s.edit_gen(),
3710            g0,
3711            "an applied action must advance the refresh generation",
3712        );
3713        // Reading the generation is not a mutation — idle frames stay put.
3714        let g1 = s.edit_gen();
3715        assert_eq!(s.edit_gen(), g1, "reading edit_gen must not advance it");
3716    }
3717
3718    /// The M1 refresh node (theory/ESCRIBA.md §X): a mutation widens the typed
3719    /// `Damage` to cover exactly what changed — local for an in-place edit,
3720    /// to-end-of-document when the line count shifts — and the renderer drains
3721    /// it per frame. `Damage ⊇ changed` by construction; it never narrows.
3722    #[test]
3723    fn damage_tracks_edit_scope_and_drains() {
3724        let mut s = new_state_with("hello\nworld\n");
3725        assert!(s.damage().is_none(), "a fresh state has no damage");
3726
3727        s.apply(&Action::InsertChar('X')); // in-place edit on line 0
3728        assert_eq!(
3729            s.damage(),
3730            Damage::Lines { from: 0, to: 0 },
3731            "a local edit damages just its line",
3732        );
3733
3734        let drained = s.take_damage();
3735        assert_eq!(drained, Damage::Lines { from: 0, to: 0 });
3736        assert!(s.damage().is_none(), "take_damage drains to None");
3737
3738        s.apply(&Action::InsertChar('\n')); // splits line 0 → line count grows
3739        assert_eq!(
3740            s.damage(),
3741            Damage::Lines {
3742                from: 0,
3743                to: u32::MAX,
3744            },
3745            "a line-count change damages to end-of-document",
3746        );
3747    }
3748
3749    /// A state whose active window is a deliberately tiny viewport
3750    /// (`visible_lines` × `visible_columns`) so the scroll-to-contain
3751    /// invariant is exercised on small inputs.
3752    fn new_state_small_viewport(text: &str, vis_lines: u32, vis_cols: u32) -> EditorState {
3753        let mut s = new_state_with(text);
3754        for w in s.layout.windows_mut() {
3755            w.viewport.visible_lines = vis_lines;
3756            w.viewport.visible_columns = vis_cols;
3757        }
3758        s
3759    }
3760
3761    /// The core regression invariant: the active window's viewport CONTAINS
3762    /// the cursor on BOTH axes. This is the operator's exact complaint —
3763    /// "typing past the bottom (or right) leaves the cursor off-screen" —
3764    /// made into a checkable property.
3765    fn assert_cursor_in_viewport(s: &EditorState, ctx: &str) {
3766        let w = s.layout.active_window().expect("active window");
3767        let v = w.viewport;
3768        let c = s.cursor();
3769        assert!(
3770            v.top_line <= c.line && c.line < v.top_line + v.visible_lines,
3771            "[{ctx}] cursor line {} not in vertical window [{}, {}); viewport={v:?}",
3772            c.line,
3773            v.top_line,
3774            v.top_line + v.visible_lines,
3775        );
3776        assert!(
3777            v.left_column <= c.column && c.column < v.left_column + v.visible_columns,
3778            "[{ctx}] cursor column {} not in horizontal window [{}, {}); viewport={v:?}",
3779            c.column,
3780            v.left_column,
3781            v.left_column + v.visible_columns,
3782        );
3783    }
3784
3785    /// `dd` from the KEYBOARD, not from a synthesized action.
3786    ///
3787    /// The FSM composition is unit-tested, but what an operator actually
3788    /// does is press `d` twice — and that path goes through the keymap and
3789    /// the sequence stepper, either of which could swallow the second `d`.
3790    #[test]
3791    fn pressing_d_twice_deletes_the_line() {
3792        let mut st = new_state_with("alpha\nbeta\ngamma\n");
3793        st.set_cursor(Position::new(1, 0));
3794        st.tick(&press(KeyCode::Char('d')));
3795        st.tick(&press(KeyCode::Char('d')));
3796        let got = st
3797            .buffers
3798            .get(st.active)
3799            .map(|b| b.to_string())
3800            .unwrap_or_default();
3801        assert_eq!(got, "alpha\ngamma\n", "dd from the keyboard");
3802    }
3803
3804    #[test]
3805    fn pressing_2_d_d_deletes_two_lines() {
3806        let mut st = new_state_with("a\nb\nc\nd\n");
3807        st.set_cursor(Position::new(0, 0));
3808        for k in ['2', 'd', 'd'] {
3809            st.tick(&press(KeyCode::Char(k)));
3810        }
3811        let got = st
3812            .buffers
3813            .get(st.active)
3814            .map(|b| b.to_string())
3815            .unwrap_or_default();
3816        assert_eq!(got, "c\nd\n", "count applies to the doubled operator");
3817    }
3818
3819    /// Text objects FROM THE KEYBOARD — the layer the last commit said was
3820    /// missing. `i` is `ChangeMode(Insert)` in Normal and `a` and every
3821    /// bracket are unbound, so all of this had to be decided on the KEY.
3822
3823    fn keys(text: &str, line: u32, col: u32, seq: &str) -> String {
3824        let mut st = new_state_with(text);
3825        st.set_cursor(Position::new(line, col));
3826        for c in seq.chars() {
3827            st.tick(&press(KeyCode::Char(c)));
3828        }
3829        st.buffers
3830            .get(st.active)
3831            .map(|b| b.to_string())
3832            .unwrap_or_default()
3833    }
3834
3835    #[test]
3836    fn diw_from_the_keyboard() {
3837        assert_eq!(keys("one two three\n", 0, 5, "diw"), "one  three\n");
3838    }
3839
3840    #[test]
3841    fn daw_from_the_keyboard_takes_the_space() {
3842        assert_eq!(keys("one two three\n", 0, 5, "daw"), "one three\n");
3843    }
3844
3845    #[test]
3846    fn ciw_deletes_and_enters_insert() {
3847        let mut st = new_state_with("one two\n");
3848        st.set_cursor(Position::new(0, 5));
3849        for c in "ciw".chars() {
3850            st.tick(&press(KeyCode::Char(c)));
3851        }
3852        assert_eq!(st.modal.mode(), Mode::Insert, "change leaves you inserting");
3853        let got = st
3854            .buffers
3855            .get(st.active)
3856            .map(|b| b.to_string())
3857            .unwrap_or_default();
3858        assert_eq!(got, "one \n");
3859    }
3860
3861    #[test]
3862    fn di_paren_and_da_paren_from_the_keyboard() {
3863        assert_eq!(keys("f(a, b)\n", 0, 3, "di("), "f()\n");
3864        assert_eq!(keys("f(a, b)\n", 0, 3, "da("), "f\n");
3865    }
3866
3867    #[test]
3868    fn the_closing_bracket_and_b_are_aliases() {
3869        // vim accepts `i(`, `i)` and `ib` for the same object.
3870        for sel in ["di(", "di)", "dib"] {
3871            assert_eq!(keys("f(a, b)\n", 0, 3, sel), "f()\n", "{sel}");
3872        }
3873    }
3874
3875    #[test]
3876    fn di_quote_from_the_keyboard() {
3877        assert_eq!(keys("say \"hi\" ok\n", 0, 6, "di\""), "say \"\" ok\n");
3878    }
3879
3880    #[test]
3881    fn i_alone_still_enters_insert_when_no_operator_is_pending() {
3882        // The load-bearing negative: the object layer must not steal `i`
3883        // from ordinary use.
3884        let mut st = new_state_with("abc\n");
3885        st.tick(&press(KeyCode::Char('i')));
3886        assert_eq!(st.modal.mode(), Mode::Insert);
3887    }
3888
3889    #[test]
3890    fn an_unknown_object_key_cancels_rather_than_staying_armed() {
3891        // `diz` is not an object. The operator must disarm, and the buffer
3892        // must be untouched — not left waiting to eat the next keystroke.
3893        let mut st = new_state_with("one two\n");
3894        st.set_cursor(Position::new(0, 5));
3895        for c in "diz".chars() {
3896            st.tick(&press(KeyCode::Char(c)));
3897        }
3898        let got = st
3899            .buffers
3900            .get(st.active)
3901            .map(|b| b.to_string())
3902            .unwrap_or_default();
3903        assert_eq!(got, "one two\n", "nothing was deleted");
3904        assert_eq!(*st.op_pending.state(), OpState::Resting, "and it disarmed");
3905    }
3906
3907    // ── the register under a count ───────────────────────────────────
3908
3909    #[test]
3910    fn a_counted_delete_puts_ALL_of_it_in_the_register() {
3911        // `3dw` is one delete of three words as far as the register is
3912        // concerned. Each repetition emits its own Yank, and each used to
3913        // overwrite — so `3dwP` put back only the third word and silently
3914        // lost two.
3915        let mut st = new_state_with("one two three four\n");
3916        st.set_cursor(Position::new(0, 0));
3917        for c in "3dw".chars() {
3918            st.tick(&press(KeyCode::Char(c)));
3919        }
3920        assert_eq!(
3921            st.register.as_deref(),
3922            Some("one two three "),
3923            "all three words, in the order they were deleted"
3924        );
3925    }
3926
3927    #[test]
3928    fn an_uncounted_delete_still_replaces_the_register() {
3929        // The combining flag must not leak: a later single delete replaces.
3930        let mut st = new_state_with("alpha beta\n");
3931        st.set_cursor(Position::new(0, 0));
3932        for c in "3dw".chars() {
3933            st.tick(&press(KeyCode::Char(c)));
3934        }
3935        let mut st2 = new_state_with("gamma delta\n");
3936        st2.set_cursor(Position::new(0, 0));
3937        for c in "dw".chars() {
3938            st2.tick(&press(KeyCode::Char(c)));
3939        }
3940        assert_eq!(st2.register.as_deref(), Some("gamma "));
3941    }
3942
3943    #[test]
3944    fn two_separate_counted_deletes_do_not_accumulate_into_each_other() {
3945        // The flag is cleared after each group, so the second `2dw` starts
3946        // from empty rather than appending to the first.
3947        let mut st = new_state_with("a b c d e f\n");
3948        st.set_cursor(Position::new(0, 0));
3949        for c in "2dw".chars() {
3950            st.tick(&press(KeyCode::Char(c)));
3951        }
3952        let first = st.register.clone();
3953        for c in "2dw".chars() {
3954            st.tick(&press(KeyCode::Char(c)));
3955        }
3956        assert_eq!(first.as_deref(), Some("a b "));
3957        assert_eq!(st.register.as_deref(), Some("c d "), "not \"a b c d \"");
3958    }
3959
3960    #[test]
3961    fn a_counted_yank_accumulates_without_changing_the_buffer() {
3962        let mut st = new_state_with("one two three\n");
3963        st.set_cursor(Position::new(0, 0));
3964        let before = st
3965            .buffers
3966            .get(st.active)
3967            .map(|b| b.to_string())
3968            .unwrap_or_default();
3969        for c in "2yw".chars() {
3970            st.tick(&press(KeyCode::Char(c)));
3971        }
3972        assert_eq!(st.register.as_deref(), Some("one two "));
3973        let after = st
3974            .buffers
3975            .get(st.active)
3976            .map(|b| b.to_string())
3977            .unwrap_or_default();
3978        assert_eq!(after, before, "yank does not edit");
3979    }
3980
3981    // ── the anchored reply (Negai::ErrandReply) ──────────────────────
3982    //
3983    // Landed BEFORE the courier that will produce these. The class being
3984    // closed: a reply computed off the tick, applied against a world that
3985    // has since moved, and RESEALED as fresh by the interpreter — which is
3986    // what every synchronous slip correctly does and what an async one must
3987    // never do.
3988
3989    fn a_finding(buffer: BufferId, line: u32) -> escriba_shirube::Finding {
3990        use escriba_core::{Position, Range};
3991        escriba_shirube::Finding::new(
3992            escriba_shirube::Site::in_buffer(
3993                buffer,
3994                Range::new(Position::new(line, 0), Position::new(line, 1)),
3995            ),
3996            escriba_shirube::Severity::Error,
3997            "computed off the tick".to_string(),
3998            escriba_shirube::Origin::Text("test"),
3999        )
4000    }
4001
4002    #[test]
4003    fn a_fresh_errand_reply_is_honoured() {
4004        let mut st = new_state_with("a\nb\nc\n");
4005        let anchor = st.world();
4006        st.honour_one(escriba_madoguchi::Negai::ErrandReply {
4007            anchor,
4008            then: Box::new(escriba_madoguchi::Negai::PublishFindings {
4009                list: "lsp".to_string(),
4010                findings: vec![a_finding(st.active, 1)],
4011            }),
4012        });
4013        assert_eq!(st.finding_items(true).len(), 1, "the world had not moved");
4014    }
4015
4016    /// THE red run. Without the freshness check this passes findings
4017    /// straight through, and `PublishFindings` reseals them with the
4018    /// CURRENT world — so they are reported fresh at columns that moved.
4019    #[test]
4020    fn a_stale_errand_reply_is_dropped_not_resealed() {
4021        let mut st = new_state_with("a\nb\nc\n");
4022        // Capture the world the "server" computed against...
4023        let anchor = st.world();
4024        // ...then let the operator keep typing, which is the whole point.
4025        st.apply(&Action::InsertChar('x'));
4026
4027        st.honour_one(escriba_madoguchi::Negai::ErrandReply {
4028            anchor,
4029            then: Box::new(escriba_madoguchi::Negai::PublishFindings {
4030                list: "lsp".to_string(),
4031                findings: vec![a_finding(st.active, 1)],
4032            }),
4033        });
4034        assert!(
4035            st.finding_items(true).is_empty(),
4036            "a reply computed against an older text revision must be DROPPED, \
4037             not resealed against the current one"
4038        );
4039    }
4040
4041    /// The failure the wrapper exists for, and the reason it wraps a slip
4042    /// rather than adding an anchor field to PublishFindings: a stale EDIT
4043    /// corrupts the file, where a stale diagnostic merely mis-decorates it.
4044    #[test]
4045    fn a_stale_errand_reply_cannot_edit_the_buffer() {
4046        let mut st = new_state_with("hello\n");
4047        let anchor = st.world();
4048        st.apply(&Action::InsertChar('!'));
4049        let before = st
4050            .buffers
4051            .get(st.active)
4052            .map(|b| b.to_string())
4053            .unwrap_or_default();
4054
4055        st.honour_one(escriba_madoguchi::Negai::ErrandReply {
4056            anchor,
4057            then: Box::new(escriba_madoguchi::Negai::Edit {
4058                buffer: st.active,
4059                edit: escriba_core::Edit {
4060                    range: Range::new(Position::new(0, 0), Position::new(0, 0)),
4061                    kind: escriba_core::EditKind::Insert {
4062                        text: "FORMATTED".to_string(),
4063                    },
4064                },
4065            }),
4066        });
4067        let after = st
4068            .buffers
4069            .get(st.active)
4070            .map(|b| b.to_string())
4071            .unwrap_or_default();
4072        assert_eq!(
4073            after, before,
4074            "a stale formatter reply must not touch the text"
4075        );
4076    }
4077
4078    fn press(kc: KeyCode) -> AppEvent {
4079        AppEvent::Key(KeyEvent {
4080            key: kc,
4081            pressed: true,
4082            modifiers: Modifiers::default(),
4083            text: None,
4084        })
4085    }
4086
4087    // ── operator-over-motion (the `dw`/`c$`/`y0` verbs) ──────────────
4088
4089    fn line0_len(s: &EditorState) -> u32 {
4090        s.buffers.get(s.active).unwrap().line_len_chars(0)
4091    }
4092
4093    #[test]
4094    fn delete_to_line_end_clears_line_and_fills_register() {
4095        let mut s = new_state_with("hello world");
4096        s.apply(&Action::ApplyOperator {
4097            op: Operator::Delete,
4098            motion: Motion::LineEnd,
4099        });
4100        assert_eq!(line0_len(&s), 0, "d$ deletes to end of line");
4101        assert_eq!(
4102            s.register(),
4103            Some("hello world"),
4104            "delete fills the register"
4105        );
4106        assert_eq!(
4107            s.cursor(),
4108            Position::ZERO,
4109            "cursor lands at the range start"
4110        );
4111    }
4112
4113    #[test]
4114    fn delete_over_right_motion_removes_one_char() {
4115        let mut s = new_state_with("abc");
4116        s.apply(&Action::ApplyOperator {
4117            op: Operator::Delete,
4118            motion: Motion::Right,
4119        });
4120        assert_eq!(
4121            s.buffers.get(s.active).unwrap().line(0).as_deref(),
4122            Some("bc")
4123        );
4124        assert_eq!(s.register(), Some("a"));
4125    }
4126
4127    #[test]
4128    fn change_to_line_end_deletes_and_enters_insert() {
4129        let mut s = new_state_with("hello world");
4130        assert_eq!(s.modal.mode(), Mode::Normal);
4131        s.apply(&Action::ApplyOperator {
4132            op: Operator::Change,
4133            motion: Motion::LineEnd,
4134        });
4135        assert_eq!(line0_len(&s), 0, "c$ deletes the range");
4136        assert_eq!(
4137            s.modal.mode(),
4138            Mode::Insert,
4139            "change enters Insert to type the replacement"
4140        );
4141        assert_eq!(
4142            s.register(),
4143            Some("hello world"),
4144            "change fills the register"
4145        );
4146    }
4147
4148    #[test]
4149    fn yank_to_line_end_fills_register_without_mutating() {
4150        let mut s = new_state_with("hello world");
4151        s.apply(&Action::ApplyOperator {
4152            op: Operator::Yank,
4153            motion: Motion::LineEnd,
4154        });
4155        assert_eq!(line0_len(&s), 11, "yank does not mutate the buffer");
4156        assert_eq!(s.register(), Some("hello world"), "yank fills the register");
4157        assert_eq!(s.modal.mode(), Mode::Normal, "yank stays in Normal");
4158    }
4159
4160    #[test]
4161    fn resolve_motion_is_the_shared_target_for_move_and_operator() {
4162        // The encapsulation proof: apply_motion (cursor move) and
4163        // apply_operator (range end) BOTH stand on resolve_motion — so a move
4164        // to LineEnd lands at exactly the position the operator deletes to.
4165        let mut s = new_state_with("hello world");
4166        let target = s.resolve_motion(Position::ZERO, Motion::LineEnd).unwrap();
4167        assert_eq!(target, Position::new(0, 11));
4168        s.apply_motion(Motion::LineEnd);
4169        assert_eq!(
4170            s.cursor(),
4171            target,
4172            "the move path resolves the same target the operator uses"
4173        );
4174    }
4175
4176    #[test]
4177    fn empty_motion_range_is_a_no_op() {
4178        // An operator over a zero-width motion (cursor already at line start)
4179        // mutates nothing and leaves the register untouched.
4180        let mut s = new_state_with("abc");
4181        s.apply(&Action::ApplyOperator {
4182            op: Operator::Delete,
4183            motion: Motion::LineStart,
4184        });
4185        assert_eq!(
4186            s.buffers.get(s.active).unwrap().line(0).as_deref(),
4187            Some("abc")
4188        );
4189        assert_eq!(s.register(), None);
4190    }
4191
4192    #[test]
4193    fn operator_then_motion_composes_through_the_pending_fsm() {
4194        // The full keymap→FSM→engine path: dispatching the `d` operator action
4195        // then a `$` motion composes `d$` via the zenmai operator-pending FSM —
4196        // the operator key alone does nothing until the motion arrives.
4197        let mut s = new_state_with("hello world");
4198        s.apply(&Action::Operator(Operator::Delete));
4199        assert_eq!(line0_len(&s), 11, "the operator key alone mutates nothing");
4200        s.apply(&Action::Move(Motion::LineEnd));
4201        assert_eq!(
4202            line0_len(&s),
4203            0,
4204            "d then $ composes d$ and deletes the line"
4205        );
4206        assert_eq!(s.register(), Some("hello world"));
4207    }
4208
4209    #[test]
4210    fn change_operator_through_fsm_enters_insert() {
4211        let mut s = new_state_with("hello world");
4212        s.apply(&Action::Operator(Operator::Change));
4213        s.apply(&Action::Move(Motion::LineEnd));
4214        assert_eq!(s.modal.mode(), Mode::Insert, "c$ deletes and enters Insert");
4215    }
4216
4217    #[test]
4218    fn lone_motion_after_no_operator_just_moves() {
4219        // Without a preceding operator the motion passes through unchanged.
4220        let mut s = new_state_with("hello world");
4221        s.apply(&Action::Move(Motion::LineEnd));
4222        assert_eq!(s.cursor(), Position::new(0, 11));
4223        assert_eq!(line0_len(&s), 11, "a bare motion never mutates");
4224    }
4225
4226    #[test]
4227    fn counted_operator_deletes_count_times() {
4228        // `3d` + a right-motion = `3dl` = delete 3 chars. The operator's count
4229        // flows through the FSM to the composed motion (the bug fix: previously
4230        // the count repeated the operator key and toggled the FSM).
4231        let mut s = new_state_with("abcdef");
4232        s.apply_counted(&Action::Operator(Operator::Delete), 3);
4233        assert_eq!(line0_len(&s), 6, "the operator key alone mutates nothing");
4234        s.apply(&Action::Move(Motion::Right));
4235        assert_eq!(
4236            s.buffers.get(s.active).unwrap().line(0).as_deref(),
4237            Some("def")
4238        );
4239    }
4240
4241    #[test]
4242    fn operator_and_motion_counts_multiply_end_to_end() {
4243        // `2d3l` = delete 2×3 = 6 chars.
4244        let mut s = new_state_with("abcdefgh");
4245        s.apply_counted(&Action::Operator(Operator::Delete), 2);
4246        s.apply_counted(&Action::Move(Motion::Right), 3);
4247        assert_eq!(
4248            s.buffers.get(s.active).unwrap().line(0).as_deref(),
4249            Some("gh")
4250        );
4251    }
4252
4253    #[test]
4254    fn bare_counted_motion_still_repeats_no_regression() {
4255        // `3j` still moves down 3 lines — the count passes through the FSM
4256        // unchanged when no operator is pending.
4257        let mut s = new_state_with("a\nb\nc\nd\ne");
4258        s.apply_counted(&Action::Move(Motion::Down), 3);
4259        assert_eq!(s.cursor().line, 3, "5j-style counted motion preserved");
4260    }
4261
4262    /// A monotonic clock for the key-repeat gate in tests — each `next()`
4263    /// jumps a full second past the previous, so every press it stamps is
4264    /// well outside the 80ms debounce window and therefore an INTENTIONAL
4265    /// press (never a storm tick). Used by tests that fire the *same*
4266    /// navigation key twice and assert editor logic, not debounce timing.
4267    struct SpacedClock(std::time::Instant);
4268    impl SpacedClock {
4269        fn new() -> Self {
4270            Self(std::time::Instant::now())
4271        }
4272        fn next(&mut self) -> std::time::Instant {
4273            self.0 += std::time::Duration::from_secs(1);
4274            self.0
4275        }
4276    }
4277
4278    #[test]
4279    fn hjkl_moves_cursor() {
4280        let mut s = new_state_with("hello\nworld");
4281        s.tick(&press(KeyCode::Char('l')));
4282        assert_eq!(s.cursor().column, 1);
4283        s.tick(&press(KeyCode::Char('j')));
4284        assert_eq!(s.cursor().line, 1);
4285        s.tick(&press(KeyCode::Char('h')));
4286        assert_eq!(s.cursor().column, 0);
4287    }
4288
4289    #[test]
4290    fn insert_mode_inserts_chars() {
4291        let mut s = new_state_with("");
4292        s.tick(&press(KeyCode::Char('i')));
4293        assert_eq!(s.modal.mode(), Mode::Insert);
4294        s.tick(&press(KeyCode::Char('h')));
4295        s.tick(&press(KeyCode::Char('i')));
4296        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "hi");
4297        assert_eq!(s.cursor().column, 2);
4298    }
4299
4300    #[test]
4301    fn esc_returns_to_normal() {
4302        let mut s = new_state_with("");
4303        s.tick(&press(KeyCode::Char('i')));
4304        s.tick(&press(KeyCode::Escape));
4305        assert_eq!(s.modal.mode(), Mode::Normal);
4306    }
4307
4308    #[test]
4309    fn count_prefix_repeats_motion() {
4310        let mut s = new_state_with("abcdefghij");
4311        s.tick(&press(KeyCode::Char('5')));
4312        s.tick(&press(KeyCode::Char('l')));
4313        assert_eq!(s.cursor().column, 5);
4314    }
4315
4316    #[test]
4317    fn close_event_requests_quit() {
4318        let mut s = new_state_with("");
4319        s.tick(&AppEvent::CloseRequested);
4320        assert!(s.quit_requested);
4321    }
4322
4323    #[test]
4324    fn word_next_jumps_past_whitespace() {
4325        let mut s = new_state_with("foo bar baz");
4326        // Two INTENTIONAL `w` presses, spaced past the key-repeat window so
4327        // the gate passes both (a real user's two taps are ≥80ms apart).
4328        let mut clk = SpacedClock::new();
4329        s.tick_at(&press(KeyCode::Char('w')), clk.next());
4330        assert_eq!(s.cursor().column, 4);
4331        s.tick_at(&press(KeyCode::Char('w')), clk.next());
4332        assert_eq!(s.cursor().column, 8);
4333    }
4334
4335    // ── Multi-key / leader pending-stroke ───────────────────────────
4336
4337    #[test]
4338    fn leader_sequence_holds_then_resolves() {
4339        let mut s = new_state_with("a\nbb\nccc");
4340        s.keymap.bind_sequence(
4341            Mode::Normal,
4342            vec![Key::Char(','), Key::Char('g')],
4343            Action::Move(Motion::DocEnd),
4344            "doc end",
4345        );
4346        // `,` begins the sequence — held pending, nothing applied yet.
4347        s.on_key(&Key::Char(','));
4348        assert_eq!(s.pending_keys, vec![Key::Char(',')]);
4349        assert_eq!(s.cursor(), Position::ZERO);
4350        // `g` completes `<leader>g` → DocEnd; pending clears.
4351        s.on_key(&Key::Char('g'));
4352        assert!(s.pending_keys.is_empty());
4353        assert_eq!(s.cursor().line, 2);
4354    }
4355
4356    #[test]
4357    fn two_key_gg_jumps_doc_start() {
4358        let mut s = new_state_with("a\nbb\nccc");
4359        s.keymap.bind_sequence(
4360            Mode::Normal,
4361            vec![Key::Char('g'), Key::Char('g')],
4362            Action::Move(Motion::DocStart),
4363            "doc start",
4364        );
4365        let mut clk = SpacedClock::new();
4366        s.tick_at(&press(KeyCode::Char('j')), clk.next());
4367        s.tick_at(&press(KeyCode::Char('j')), clk.next());
4368        assert_eq!(s.cursor().line, 2);
4369        s.on_key(&Key::Char('g')); // pending
4370        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
4371        s.on_key(&Key::Char('g')); // resolve
4372        assert_eq!(s.cursor(), Position::ZERO);
4373    }
4374
4375    #[test]
4376    fn broken_sequence_aborts_and_clears_pending() {
4377        let mut s = new_state_with("hello");
4378        s.keymap.bind_sequence(
4379            Mode::Normal,
4380            vec![Key::Char('g'), Key::Char('g')],
4381            Action::Move(Motion::DocEnd),
4382            "doc end",
4383        );
4384        s.on_key(&Key::Char('g')); // pending [g]
4385        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
4386        s.on_key(&Key::Char('x')); // breaks gg → abort; x is unbound → no-op
4387        assert!(s.pending_keys.is_empty());
4388        assert_eq!(s.cursor(), Position::ZERO);
4389    }
4390
4391    #[test]
4392    fn single_binding_wins_over_sequence_prefix() {
4393        // A key that is BOTH a complete single binding and the start of
4394        // a sequence fires the single binding immediately (no chord
4395        // timeout needed). Here `h` (move-left) also prefixes `hz`.
4396        let mut s = new_state_with("abcde");
4397        let mut clk = SpacedClock::new();
4398        s.tick_at(&press(KeyCode::Char('l')), clk.next());
4399        s.tick_at(&press(KeyCode::Char('l')), clk.next());
4400        assert_eq!(s.cursor().column, 2);
4401        s.keymap.bind_sequence(
4402            Mode::Normal,
4403            vec![Key::Char('h'), Key::Char('z')],
4404            Action::Move(Motion::DocEnd),
4405            "shadowed",
4406        );
4407        s.on_key(&Key::Char('h'));
4408        assert!(s.pending_keys.is_empty(), "single binding should not pend");
4409        assert_eq!(s.cursor().column, 1, "h moved left immediately");
4410    }
4411
4412    // ── tatara-lisp runtime bridge (imperative programmability) ─────
4413
4414    #[test]
4415    fn lisp_set_option_writes_live_options() {
4416        let mut s = new_state_with("");
4417        s.run_lisp(r#"(set-option "number" "true")"#).unwrap();
4418        assert_eq!(s.options.get("number").map(String::as_str), Some("true"));
4419    }
4420
4421    #[test]
4422    fn lisp_insert_modifies_buffer_and_advances_cursor() {
4423        let mut s = new_state_with("");
4424        s.run_lisp(r#"(insert "abc")"#).unwrap();
4425        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
4426        assert_eq!(s.cursor(), Position::new(0, 3));
4427    }
4428
4429    #[test]
4430    fn lisp_message_appends_to_messages() {
4431        let mut s = new_state_with("");
4432        s.run_lisp(r#"(message "hello from lisp")"#).unwrap();
4433        assert_eq!(s.messages, vec!["hello from lisp".to_string()]);
4434    }
4435
4436    #[test]
4437    fn lisp_reads_snapshot_and_branches_to_effect() {
4438        // Genuine programmability: Lisp reads the live cursor line and
4439        // an `if` decides which option to set.
4440        let mut s = new_state_with("one\ntwo\nthree");
4441        // cursor at line 0 → "top" branch
4442        s.run_lisp(r#"(if (= (cursor-line) 0) (set-option "pos" "top") (set-option "pos" "mid"))"#)
4443            .unwrap();
4444        assert_eq!(s.options.get("pos").map(String::as_str), Some("top"));
4445    }
4446
4447    #[test]
4448    fn lisp_run_command_effect_drives_registry() {
4449        // `(run-command "undo")` reaches the live command registry and
4450        // reverts a prior Lisp-driven insert — proving the RunCommand
4451        // effect dispatches through real editor commands.
4452        let mut s = new_state_with("");
4453        s.run_lisp(r#"(insert "abc")"#).unwrap();
4454        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
4455        s.run_lisp(r#"(run-command "undo")"#).unwrap();
4456        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "");
4457    }
4458
4459    #[test]
4460    fn lisp_run_command_quit_sets_quit_requested_via_typed_flag() {
4461        // The full imperative-quit path: (run-command "quit") routes
4462        // through the registry's typed `quit_requested` signal — no string
4463        // sentinel, and no minibuffer pollution (the editor stays in a
4464        // clean Normal state, which has no minibuffer at all).
4465        let mut s = new_state_with("");
4466        s.run_lisp(r#"(run-command "quit")"#).unwrap();
4467        assert!(s.quit_requested, "lisp-driven quit must set quit_requested");
4468        assert_eq!(
4469            s.modal.minibuffer(),
4470            "",
4471            "quit must not pollute any command line — Normal mode has no minibuffer",
4472        );
4473    }
4474
4475    // ── Lazy plugin activation (PluginHost) ────────────────────────
4476
4477    #[test]
4478    fn lazy_plugin_activates_on_command_trigger() {
4479        // A user plugin gated on `Command: LazyGo` has its entry applied
4480        // the first time that command runs — proving the lazy.nvim
4481        // `cmd =` model works end-to-end against live editor state.
4482        let mut s = new_state_with("");
4483        s.register_lazy_plugin(
4484            "user-lazy",
4485            vec![LazyTrigger::Command("LazyGo".into())],
4486            r#"(defoption :name "lazy-loaded" :value "yes")
4487               (defcmd :name "LazyGo" :description "noop" :action "editor.noop")"#,
4488        );
4489        assert_eq!(s.plugin_host.pending(), 1);
4490        assert!(
4491            s.options.get("lazy-loaded").is_none(),
4492            "entry not applied yet"
4493        );
4494
4495        // Drive the command through the public imperative path.
4496        s.run_lisp(r#"(run-command "LazyGo")"#).unwrap();
4497
4498        assert_eq!(
4499            s.options.get("lazy-loaded").map(String::as_str),
4500            Some("yes"),
4501            "the command trigger applied the plugin's entry",
4502        );
4503        assert_eq!(s.plugin_host.pending(), 0, "plugin activated exactly once");
4504    }
4505
4506    #[test]
4507    fn lazy_plugin_activates_on_filetype() {
4508        let mut s = new_state_with("");
4509        s.register_lazy_plugin(
4510            "user-rust",
4511            vec![LazyTrigger::FileType("rust".into())],
4512            r#"(defoption :name "rust-plugin" :value "on")"#,
4513        );
4514        let n = s.activate_filetype_plugins("rust");
4515        assert_eq!(n, 1);
4516        assert_eq!(s.options.get("rust-plugin").map(String::as_str), Some("on"));
4517        // A second open of the same filetype is a no-op (one-shot).
4518        assert_eq!(s.activate_filetype_plugins("rust"), 0);
4519    }
4520
4521    #[test]
4522    fn cached_vm_serves_multiple_run_lisp_calls() {
4523        let mut s = new_state_with("");
4524        s.run_lisp(r#"(message "one")"#).unwrap();
4525        assert!(
4526            s.lisp_vm.is_some(),
4527            "VM should be cached after first run_lisp"
4528        );
4529        s.run_lisp(r#"(message "two")"#).unwrap();
4530        assert_eq!(s.messages, vec!["one".to_string(), "two".to_string()]);
4531    }
4532
4533    #[test]
4534    fn lisp_define_persists_across_run_lisp_calls() {
4535        // The cached VM's top-level env persists across calls (REPL
4536        // semantics): a `define` in one call is visible in the next.
4537        let mut s = new_state_with("");
4538        s.run_lisp(r#"(define greeting "hi")"#).unwrap();
4539        s.run_lisp(r#"(message greeting)"#).unwrap();
4540        assert_eq!(s.messages, vec!["hi".to_string()]);
4541    }
4542
4543    #[test]
4544    fn snapshot_is_isolated_within_one_run_lisp_call_and_refreshes_across() {
4545        // Within ONE call a program cannot observe its own writes — the
4546        // read snapshot is captured before eval, effects apply after. A
4547        // later call sees the refreshed snapshot.
4548        let mut s = new_state_with("");
4549        s.run_lisp(
4550            r#"(insert "ab") (set-option "col" (if (= (cursor-column) 0) "stale-zero" "live"))"#,
4551        )
4552        .unwrap();
4553        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "ab");
4554        assert_eq!(
4555            s.options.get("col").map(String::as_str),
4556            Some("stale-zero"),
4557            "cursor-column within the same call reads the pre-eval snapshot",
4558        );
4559        // After the first call the cursor advanced to column 2; the next
4560        // call's snapshot reflects it.
4561        s.run_lisp(r#"(set-option "col2" (if (= (cursor-column) 2) "live-two" "other"))"#)
4562            .unwrap();
4563        assert_eq!(
4564            s.options.get("col2").map(String::as_str),
4565            Some("live-two"),
4566            "a later call sees the refreshed snapshot",
4567        );
4568    }
4569
4570    #[test]
4571    fn insert_text_effect_multiline_lands_cursor_on_last_line() {
4572        let mut s = new_state_with("");
4573        s.apply_host_effects(vec![Negai::InsertText("foo\nbar".to_string())]);
4574        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "foo\nbar");
4575        assert_eq!(s.cursor(), Position::new(1, 3));
4576    }
4577
4578    #[test]
4579    fn visual_mode_sequence_resolves() {
4580        let mut s = new_state_with("abc");
4581        s.modal.enter(Mode::Visual);
4582        s.keymap.bind_sequence(
4583            Mode::Visual,
4584            vec![Key::Char('g'), Key::Char('e')],
4585            Action::Move(Motion::DocEnd),
4586            "ge",
4587        );
4588        s.on_key(&Key::Char('g'));
4589        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
4590        s.on_key(&Key::Char('e'));
4591        assert!(s.pending_keys.is_empty());
4592        assert_eq!(
4593            s.cursor().column,
4594            3,
4595            "ge resolved to doc-end in visual mode"
4596        );
4597    }
4598
4599    #[test]
4600    fn sequence_abort_with_bound_breaking_key_redispatches() {
4601        // gg is a sequence; `l` (move-right) is a bound single key. After
4602        // `g` pends, `l` breaks gg, aborts, and is re-dispatched fresh.
4603        let mut s = new_state_with("abcde");
4604        s.keymap.bind_sequence(
4605            Mode::Normal,
4606            vec![Key::Char('g'), Key::Char('g')],
4607            Action::Move(Motion::DocEnd),
4608            "gg",
4609        );
4610        s.on_key(&Key::Char('g'));
4611        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
4612        s.on_key(&Key::Char('l'));
4613        assert!(s.pending_keys.is_empty());
4614        assert_eq!(
4615            s.cursor().column,
4616            1,
4617            "the breaking key l should re-dispatch as move-right",
4618        );
4619    }
4620
4621    // ── Viewport-follows-cursor invariant (both axes) ───────────────
4622
4623    #[test]
4624    fn viewport_contains_cursor_after_every_op() {
4625        // Tiny window: 5 visible lines × 10 visible columns. Drive a
4626        // representative scripted sequence and assert the viewport contains
4627        // the cursor after EVERY mutating step.
4628        let mut s = new_state_small_viewport("", 5, 10);
4629        assert_cursor_in_viewport(&s, "initial");
4630
4631        // Enter insert mode and type 30 newline-separated lines — this is
4632        // the exact "type past the bottom" complaint.
4633        s.tick(&press(KeyCode::Char('i')));
4634        assert_eq!(s.modal.mode(), Mode::Insert);
4635        for line in 0..30u32 {
4636            for c in "line".chars() {
4637                s.tick(&press(KeyCode::Char(c)));
4638                assert_cursor_in_viewport(&s, "typing chars");
4639            }
4640            s.tick(&press(KeyCode::Enter));
4641            assert_cursor_in_viewport(&s, &format!("newline after line {line}"));
4642        }
4643
4644        // Type a long (200-char) line — the "type past the right edge"
4645        // complaint. The cursor must stay horizontally visible the whole way.
4646        for i in 0..200u32 {
4647            s.tick(&press(KeyCode::Char('x')));
4648            assert_cursor_in_viewport(&s, &format!("long-line char {i}"));
4649        }
4650
4651        // Multi-line insert_text effect (the `(insert …)` Lisp path).
4652        s.insert_text("alpha\nbeta\ngamma delta epsilon zeta");
4653        assert_cursor_in_viewport(&s, "insert_text multiline");
4654
4655        // Back to normal mode and move in all directions / to extremes.
4656        s.tick(&press(KeyCode::Escape));
4657        assert_eq!(s.modal.mode(), Mode::Normal);
4658        for m in [
4659            Motion::DocStart,
4660            Motion::DocEnd,
4661            Motion::Down,
4662            Motion::Down,
4663            Motion::Up,
4664            Motion::Right,
4665            Motion::Right,
4666            Motion::Left,
4667            Motion::LineEnd,
4668            Motion::LineStart,
4669            Motion::GotoLine(1),
4670            Motion::GotoLine(40),
4671            Motion::PageDown,
4672            Motion::PageUp,
4673        ] {
4674            s.apply_motion(m);
4675            assert_cursor_in_viewport(&s, &format!("after motion {m:?}"));
4676        }
4677
4678        // Undo many times — the buffer shrinks; the viewport must re-follow
4679        // the (now clamped) cursor.
4680        for i in 0..50u32 {
4681            s.apply(&Action::Undo);
4682            assert_cursor_in_viewport(&s, &format!("undo {i}"));
4683        }
4684        // Redo back up.
4685        for i in 0..50u32 {
4686            s.apply(&Action::Redo);
4687            assert_cursor_in_viewport(&s, &format!("redo {i}"));
4688        }
4689    }
4690
4691    #[test]
4692    fn insert_at_eof_keeps_cursor_in_bounds() {
4693        // Inserting at the end of the buffer must leave the cursor clamped
4694        // to a valid position (and inside the viewport).
4695        let mut s = new_state_small_viewport("abc", 5, 10);
4696        s.apply_motion(Motion::DocEnd);
4697        s.tick(&press(KeyCode::Char('i')));
4698        s.tick(&press(KeyCode::Char('d')));
4699        let buf = s.buffers.get(s.active).unwrap();
4700        let clamped = buf.clamp(s.cursor());
4701        assert_eq!(
4702            s.cursor(),
4703            clamped,
4704            "cursor must be clamped in-bounds at EOF"
4705        );
4706        assert_cursor_in_viewport(&s, "insert at eof");
4707    }
4708
4709    #[test]
4710    fn count_prefix_then_sequence_repeats() {
4711        // `2` then `gj` (→ move-down) repeats the resolved action twice.
4712        let mut s = new_state_with("a\nb\nc\nd\ne");
4713        s.keymap.bind_sequence(
4714            Mode::Normal,
4715            vec![Key::Char('g'), Key::Char('j')],
4716            Action::Move(Motion::Down),
4717            "gj",
4718        );
4719        s.on_key(&Key::Char('2'));
4720        s.on_key(&Key::Char('g'));
4721        s.on_key(&Key::Char('j'));
4722        assert_eq!(s.cursor().line, 2, "count 2 should repeat the gj motion");
4723    }
4724
4725    // ── Key-repeat gate (awase::KeyRepeatGate) ──────────────────────────
4726
4727    #[test]
4728    fn held_key_repeat_storm_is_debounced_in_normal_mode() {
4729        // The audit's exact complaint: holding `j` floods motion events
4730        // and thrashes the viewport. Simulate an OS key-repeat storm — 20
4731        // identical `j` KeyDowns at 50ms intervals (typical repeat cadence)
4732        // — and assert only the gated subset (one per 80ms window) actually
4733        // moves the cursor.
4734        let mut s = new_state_with(&"x\n".repeat(40));
4735        let t0 = std::time::Instant::now();
4736        let mut delivered = 0u32;
4737        for i in 0..20u32 {
4738            let before = s.cursor().line;
4739            s.tick_at(
4740                &press(KeyCode::Char('j')),
4741                t0 + std::time::Duration::from_millis(u64::from(i) * 50),
4742            );
4743            if s.cursor().line != before {
4744                delivered += 1;
4745            }
4746        }
4747        // 20 events over ~1s at 50ms spacing, 80ms gate ⇒ ~13 pass — far
4748        // fewer than the 20 the ungated path would have applied.
4749        assert!(
4750            (10..=14).contains(&delivered),
4751            "expected the storm debounced to ~13 moves, got {delivered}",
4752        );
4753        assert!(
4754            delivered < 20,
4755            "the gate must drop SOME storm ticks, not pass all 20",
4756        );
4757    }
4758
4759    #[test]
4760    fn spaced_intentional_taps_all_pass() {
4761        // Intentional taps spaced past the debounce window must ALL reach
4762        // the editor — the gate filters storms, never deliberate input.
4763        let mut s = new_state_with(&"x\n".repeat(10));
4764        let t0 = std::time::Instant::now();
4765        for i in 0..5u32 {
4766            s.tick_at(
4767                &press(KeyCode::Char('j')),
4768                // 100ms apart — comfortably past the 80ms window.
4769                t0 + std::time::Duration::from_millis(u64::from(i) * 100),
4770            );
4771        }
4772        assert_eq!(s.cursor().line, 5, "all 5 spaced `j` taps moved the cursor");
4773    }
4774
4775    #[test]
4776    fn distinct_keys_have_independent_clocks() {
4777        // Holding `j` must not block a simultaneous `l` — the gate keys on
4778        // the Key, so independent keys have independent windows.
4779        let mut s = new_state_with("abc\ndef\nghi");
4780        let t = std::time::Instant::now();
4781        s.tick_at(&press(KeyCode::Char('j')), t);
4782        // `j` again within the window is dropped…
4783        s.tick_at(
4784            &press(KeyCode::Char('j')),
4785            t + std::time::Duration::from_millis(10),
4786        );
4787        assert_eq!(s.cursor().line, 1, "second `j` within window dropped");
4788        // …but `l` at the same instant passes (its own clock).
4789        s.tick_at(
4790            &press(KeyCode::Char('l')),
4791            t + std::time::Duration::from_millis(10),
4792        );
4793        assert_eq!(s.cursor().column, 1, "`l` is not blocked by `j`'s clock");
4794    }
4795
4796    // ── Cursors newtype is the single cursor home ──────────────────────
4797
4798    #[test]
4799    fn cursor_home_preserves_single_cursor_behavior() {
4800        // The typed `Cursors` wrapper behaves exactly like the old bare
4801        // `Position` field for single-cursor editing: the read accessor
4802        // tracks every mutation routed through `set_cursor`, and there is
4803        // exactly one caret.
4804        let mut s = new_state_with("hello\nworld\nthere");
4805        assert_eq!(s.cursor(), Position::ZERO);
4806        assert_eq!(s.cursors.count(), 1, "phase-1 holds exactly one caret");
4807
4808        s.apply_motion(Motion::Down);
4809        s.apply_motion(Motion::Right);
4810        s.apply_motion(Motion::Right);
4811        assert_eq!(s.cursor(), Position::new(1, 2));
4812        // Still a single caret after a sequence of motions.
4813        assert_eq!(s.cursors.count(), 1);
4814
4815        // The accessor is the SAME value the viewport-follow path read.
4816        let w = s.layout.active_window().unwrap();
4817        assert!(w.viewport.top_line <= s.cursor().line);
4818    }
4819
4820    #[test]
4821    fn insert_mode_is_ungated_so_repeat_typing_works() {
4822        // Holding a key to repeat-type a character is intended in Insert
4823        // mode — the gate must NOT suppress it. 10 rapid identical `x`
4824        // keystrokes at the same instant must all land as text.
4825        let mut s = new_state_with("");
4826        s.tick(&press(KeyCode::Char('i')));
4827        assert_eq!(s.modal.mode(), Mode::Insert);
4828        let t = std::time::Instant::now();
4829        for _ in 0..10 {
4830            s.tick_at(&press(KeyCode::Char('x')), t);
4831        }
4832        assert_eq!(
4833            s.buffers.get(s.active).unwrap().to_string(),
4834            "xxxxxxxxxx",
4835            "insert-mode repeat typing is ungated",
4836        );
4837    }
4838}