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