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