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