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