Skip to main content

escriba_runtime/
lib.rs

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