Skip to main content

escriba_runtime/
lib.rs

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